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

pantsbuild / pants / 20332790708

18 Dec 2025 09:48AM UTC coverage: 64.992% (-15.3%) from 80.295%
20332790708

Pull #22949

github

web-flow
Merge f730a56cd into 407284c67
Pull Request #22949: Add experimental uv resolver for Python lockfiles

54 of 97 new or added lines in 5 files covered. (55.67%)

8270 existing lines in 295 files now uncovered.

48990 of 75379 relevant lines covered (64.99%)

1.81 hits per line

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

36.57
/src/python/pants/jvm/dependency_inference/artifact_mapper.py
1
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3
from __future__ import annotations
4✔
4

5
from collections import defaultdict
4✔
6
from collections.abc import Iterable, Iterator
4✔
7
from dataclasses import dataclass
4✔
8
from typing import Any, DefaultDict
4✔
9

10
from pants.backend.java.subsystems.java_infer import JavaInferSubsystem
4✔
11
from pants.build_graph.address import Address
4✔
12
from pants.engine.rules import collect_rules, rule
4✔
13
from pants.engine.target import AllTargets, Targets
4✔
14
from pants.jvm.dependency_inference.jvm_artifact_mappings import JVM_ARTIFACT_MAPPINGS
4✔
15
from pants.jvm.resolve.common import ArtifactRequirement
4✔
16
from pants.jvm.resolve.coordinate import Coordinate
4✔
17
from pants.jvm.subsystems import JvmSubsystem
4✔
18
from pants.jvm.target_types import (
4✔
19
    JvmArtifactArtifactField,
20
    JvmArtifactGroupField,
21
    JvmArtifactPackagesField,
22
    JvmProvidesTypesField,
23
    JvmResolveField,
24
)
25
from pants.util.docutil import bin_name
4✔
26
from pants.util.frozendict import FrozenDict
4✔
27
from pants.util.logging import LogLevel
4✔
28
from pants.util.ordered_set import FrozenOrderedSet, OrderedSet
4✔
29

30
_ResolveName = str
4✔
31

32

33
@dataclass(frozen=True)
4✔
34
class UnversionedCoordinate:
4✔
35
    group: str
4✔
36
    artifact: str
4✔
37

38
    @classmethod
4✔
39
    def from_coord_str(cls, coord: str) -> UnversionedCoordinate:
4✔
40
        coordinate_parts = coord.split(":")
×
41
        if len(coordinate_parts) != 2:
×
42
            raise ValueError(f"Invalid coordinate specifier: {coord}")
×
43
        return UnversionedCoordinate(group=coordinate_parts[0], artifact=coordinate_parts[1])
×
44

45

46
class AvailableThirdPartyArtifacts(
4✔
47
    FrozenDict[
48
        tuple[_ResolveName, UnversionedCoordinate], tuple[tuple[Address, ...], tuple[str, ...]]
49
    ]
50
):
51
    """Maps coordinates and resolve names to target `Address`es and declared packages."""
52

53

54
# A namespace for a symbol which defines the scope for name collisions and ambiguity. For example:
55
# if a JVM language allows the same symbol to be declared in an unambiguous way in multiple files
56
# (such as Scala `package objects` declaring a type alias for a class/object in another file) it
57
# can use a non-default namespace name of its choice.
58
SymbolNamespace = str
4✔
59

60

61
DEFAULT_SYMBOL_NAMESPACE: SymbolNamespace = "default"
4✔
62

63

64
class MutableTrieNode:
4✔
65
    __slots__ = [
4✔
66
        "children",
67
        "recursive",
68
        "addresses",
69
        "first_party",
70
    ]  # don't use a `dict` to store attrs
71

72
    def __init__(self) -> None:
4✔
UNCOV
73
        self.children: dict[str, MutableTrieNode] = {}
×
UNCOV
74
        self.recursive: bool = False
×
UNCOV
75
        self.addresses: dict[SymbolNamespace, OrderedSet[Address]] = defaultdict(OrderedSet)
×
UNCOV
76
        self.first_party: bool = False
×
77

78
    def _ensure_child(self, name: str) -> MutableTrieNode:
4✔
UNCOV
79
        if name in self.children:
×
UNCOV
80
            return self.children[name]
×
UNCOV
81
        node = MutableTrieNode()
×
UNCOV
82
        self.children[name] = node
×
UNCOV
83
        return node
×
84

85
    def insert(
4✔
86
        self,
87
        symbol: str,
88
        addresses: Iterable[Address],
89
        *,
90
        first_party: bool,
91
        namespace: SymbolNamespace = DEFAULT_SYMBOL_NAMESPACE,
92
        recursive: bool = False,
93
    ) -> None:
UNCOV
94
        imp_parts = symbol.split(".")
×
UNCOV
95
        current_node = self
×
UNCOV
96
        for imp_part in imp_parts:
×
UNCOV
97
            child_node = current_node._ensure_child(imp_part)
×
UNCOV
98
            current_node = child_node
×
99

UNCOV
100
        current_node.addresses[namespace].update(addresses)
×
UNCOV
101
        current_node.first_party = first_party
×
UNCOV
102
        current_node.recursive = recursive
×
103

104
    def frozen(self) -> FrozenTrieNode:
4✔
UNCOV
105
        return FrozenTrieNode(self)
×
106

107

108
FrozenTrieNodeItem = tuple[str, bool, FrozenDict[SymbolNamespace, FrozenOrderedSet[Address]], bool]
4✔
109

110

111
@dataclass(frozen=True)
4✔
112
class FrozenTrieNode:
4✔
113
    __slots__ = [
4✔
114
        "_children",
115
        "_recursive",
116
        "_addresses",
117
        "_first_party",
118
    ]  # don't use a `dict` to store attrs (speeds up attr access significantly)
119

120
    _children: FrozenDict[str, FrozenTrieNode]
4✔
121
    _recursive: bool
4✔
122
    _addresses: FrozenDict[SymbolNamespace, FrozenOrderedSet[Address]]
4✔
123
    _first_party: bool
4✔
124

125
    def __init__(self, node: MutableTrieNode) -> None:
4✔
UNCOV
126
        children = {}
×
UNCOV
127
        for key, child in node.children.items():
×
UNCOV
128
            children[key] = FrozenTrieNode(child)
×
129

UNCOV
130
        object.__setattr__(self, "_children", FrozenDict(children))
×
UNCOV
131
        object.__setattr__(self, "_recursive", node.recursive)
×
UNCOV
132
        object.__setattr__(
×
133
            self,
134
            "_addresses",
135
            FrozenDict(
136
                {ns: FrozenOrderedSet(addresses) for ns, addresses in node.addresses.items()}
137
            ),
138
        )
UNCOV
139
        object.__setattr__(self, "_first_party", node.first_party)
×
140

141
    def find_child(self, name: str) -> FrozenTrieNode | None:
4✔
UNCOV
142
        return self._children.get(name)
×
143

144
    @property
4✔
145
    def recursive(self) -> bool:
4✔
146
        return self._recursive
×
147

148
    @property
4✔
149
    def first_party(self) -> bool:
4✔
150
        return self._first_party
×
151

152
    def addresses_for_symbol(
4✔
153
        self, symbol: str
154
    ) -> FrozenDict[SymbolNamespace, FrozenOrderedSet[Address]]:
155
        current_node = self
×
156
        imp_parts = symbol.split(".")
×
157

158
        found_nodes = []
×
159
        for imp_part in imp_parts:
×
160
            child_node_opt = current_node.find_child(imp_part)
×
161
            if not child_node_opt:
×
162
                break
×
163
            found_nodes.append(child_node_opt)
×
164
            current_node = child_node_opt
×
165

166
        if not found_nodes:
×
167
            return FrozenDict()
×
168

169
        # If the length of the found nodes equals the number of parts of the package path, then
170
        # there is an exact match.
171
        if len(found_nodes) == len(imp_parts):
×
172
            return found_nodes[-1].addresses
×
173

174
        # Otherwise, check for the first found node (in reverse order) to match recursively, and
175
        # use its coordinate.
176
        for found_node in reversed(found_nodes):
×
177
            if found_node.recursive:
×
178
                return found_node.addresses
×
179

180
        # Nothing matched so return no match.
181
        return FrozenDict()
×
182

183
    @property
4✔
184
    def addresses(self) -> FrozenDict[SymbolNamespace, FrozenOrderedSet[Address]]:
4✔
UNCOV
185
        return self._addresses
×
186

187
    @classmethod
4✔
188
    def merge(cls, nodes: Iterable[FrozenTrieNode]) -> FrozenTrieNode:
4✔
189
        """Merges the given `FrozenTrieNode` instances.
190

191
        TODO: This is currently implemented as merging-from-scratch, but could be trie-aware.
192
        """
UNCOV
193
        result = MutableTrieNode()
×
UNCOV
194
        for node in nodes:
×
UNCOV
195
            for symbol, recursive, address_namespaces, first_party in node:
×
UNCOV
196
                for namespace, addresses in address_namespaces.items():
×
UNCOV
197
                    result.insert(
×
198
                        symbol,
199
                        addresses,
200
                        recursive=recursive,
201
                        first_party=first_party,
202
                        namespace=namespace,
203
                    )
UNCOV
204
        return FrozenTrieNode(result)
×
205

206
    def to_json_dict(self) -> dict[str, Any]:
4✔
207
        return {
×
208
            "children": {name: child.to_json_dict() for name, child in self._children.items()},
209
            "addresses": {
210
                ns: [str(a) for a in addresses] for ns, addresses in self._addresses.items()
211
            },
212
            "recursive": self._recursive,
213
            "first_party": self._first_party,
214
        }
215

216
    def _iter_helper(self, symbol: list[str]) -> Iterator[FrozenTrieNodeItem]:
4✔
UNCOV
217
        if symbol and self._addresses:
×
UNCOV
218
            yield (".".join(symbol), self._recursive, self._addresses, self._first_party)
×
UNCOV
219
        for name, child in self._children.items():
×
UNCOV
220
            symbol.append(name)
×
UNCOV
221
            yield from child._iter_helper(symbol)
×
UNCOV
222
            symbol.pop()
×
223

224
    def __iter__(self) -> Iterator[FrozenTrieNodeItem]:
4✔
225
        """Iterates through all nodes in the trie.
226

227
        TODO: This is primarily used in `FrozenTrieNode.merge`: if that method switches to
228
        trie-aware merging, this should likely be removed.
229
        """
UNCOV
230
        yield from self._iter_helper([])
×
231

232
    def __hash__(self) -> int:
4✔
UNCOV
233
        return hash((self._children, self._recursive, self._addresses))
×
234

235
    def __eq__(self, other: Any) -> bool:
4✔
236
        if not isinstance(other, FrozenTrieNode):
×
237
            return False
×
238
        return (
×
239
            self.recursive == other.recursive
240
            and self.first_party == other.first_party
241
            and self.addresses == other.addresses
242
            and self._children == other._children
243
        )
244

245
    def __repr__(self):
4✔
246
        return f"FrozenTrieNode(children={repr(self._children)}, recursive={self._recursive}, addresses={self._addresses}, first_party={self._first_party})"
×
247

248

249
class AllJvmArtifactTargets(Targets):
4✔
250
    pass
4✔
251

252

253
class AllJvmTypeProvidingTargets(Targets):
4✔
254
    pass
4✔
255

256

257
@rule(desc="Find all jvm_artifact targets in project", level=LogLevel.DEBUG)
4✔
258
async def find_all_jvm_artifact_targets(targets: AllTargets) -> AllJvmArtifactTargets:
4✔
259
    return AllJvmArtifactTargets(
×
260
        tgt for tgt in targets if tgt.has_fields((JvmArtifactGroupField, JvmArtifactArtifactField))
261
    )
262

263

264
@rule(desc="Find all targets with experimental_provides fields in project", level=LogLevel.DEBUG)
4✔
265
async def find_all_jvm_provides_fields(targets: AllTargets) -> AllJvmTypeProvidingTargets:
4✔
266
    return AllJvmTypeProvidingTargets(
×
267
        tgt
268
        for tgt in targets
269
        if tgt.has_field(JvmProvidesTypesField) and tgt[JvmProvidesTypesField].value is not None
270
    )
271

272

273
class ThirdPartySymbolMapping(FrozenDict[_ResolveName, FrozenTrieNode]):
4✔
274
    """The third party symbols provided by all `jvm_artifact` targets."""
275

276

277
@rule
4✔
278
async def find_available_third_party_artifacts(
4✔
279
    all_jvm_artifact_tgts: AllJvmArtifactTargets, jvm: JvmSubsystem
280
) -> AvailableThirdPartyArtifacts:
281
    address_mapping: DefaultDict[
×
282
        tuple[_ResolveName, UnversionedCoordinate], OrderedSet[Address]
283
    ] = defaultdict(OrderedSet)
284
    package_mapping: DefaultDict[tuple[_ResolveName, UnversionedCoordinate], OrderedSet[str]] = (
×
285
        defaultdict(OrderedSet)
286
    )
287
    for tgt in all_jvm_artifact_tgts:
×
288
        coord = UnversionedCoordinate(
×
289
            group=tgt[JvmArtifactGroupField].value, artifact=tgt[JvmArtifactArtifactField].value
290
        )
291
        resolve = tgt[JvmResolveField].normalized_value(jvm)
×
292
        key = (resolve, coord)
×
293
        address_mapping[key].add(tgt.address)
×
294
        package_mapping[key].update(tgt[JvmArtifactPackagesField].value or ())
×
295

296
    return AvailableThirdPartyArtifacts(
×
297
        {
298
            key: (tuple(addresses), tuple(package_mapping[key]))
299
            for key, addresses in address_mapping.items()
300
        }
301
    )
302

303

304
@rule
4✔
305
async def compute_java_third_party_symbol_mapping(
4✔
306
    java_infer_subsystem: JavaInferSubsystem,
307
    available_artifacts: AvailableThirdPartyArtifacts,
308
    all_jvm_type_providing_tgts: AllJvmTypeProvidingTargets,
309
) -> ThirdPartySymbolMapping:
310
    """Implements the mapping logic from the `jvm_artifact` and `java-infer` help."""
311

312
    def symbol_from_package_pattern(package_pattern: str) -> tuple[str, bool]:
×
313
        wildcard_suffix = ".**"
×
314
        if package_pattern.endswith(wildcard_suffix):
×
315
            return package_pattern[: -len(wildcard_suffix)], True
×
316
        else:
317
            return package_pattern, False
×
318

319
    # Build a default mapping from coord to package.
320
    # TODO: Consider inverting the definitions of these mappings.
321
    default_coords_to_packages: dict[UnversionedCoordinate, OrderedSet[str]] = defaultdict(
×
322
        OrderedSet
323
    )
324
    for package, unversioned_coord_str in {
×
325
        **JVM_ARTIFACT_MAPPINGS,
326
        **java_infer_subsystem.third_party_import_mapping,
327
    }.items():
328
        unversioned_coord = UnversionedCoordinate.from_coord_str(unversioned_coord_str)
×
329
        default_coords_to_packages[unversioned_coord].add(package)
×
330

331
    # Build mappings per resolve from packages to addresses.
332
    mappings: DefaultDict[_ResolveName, MutableTrieNode] = defaultdict(MutableTrieNode)
×
333
    for (resolve_name, coord), (addresses, packages) in available_artifacts.items():
×
334
        if not packages:
×
335
            # If no packages were explicitly defined, fall back to our default mapping.
336
            packages = tuple(default_coords_to_packages[coord])
×
337
        if not packages:
×
338
            # Default to exposing the `group` name as a package.
339
            packages = (f"{coord.group}.**",)
×
340
        mapping = mappings[resolve_name]
×
341
        for package in packages:
×
342
            symbol, recursive = symbol_from_package_pattern(package)
×
343
            mapping.insert(symbol, addresses, first_party=False, recursive=recursive)
×
344

345
    # Mark types that have strong first-party declarations as first-party
346
    for tgt in all_jvm_type_providing_tgts:
×
347
        for provides_type in tgt[JvmProvidesTypesField].value or []:
×
348
            for mapping in mappings.values():
×
349
                mapping.insert(provides_type, [], first_party=True, recursive=False)
×
350

351
    return ThirdPartySymbolMapping(
×
352
        FrozenDict(
353
            (resolve_name, FrozenTrieNode(mapping)) for resolve_name, mapping in mappings.items()
354
        )
355
    )
356

357

358
class ConflictingJvmArtifactVersionInResolveError(ValueError):
4✔
359
    def __init__(
4✔
360
        self,
361
        *,
362
        subsystem: str,
363
        requirement_source: str | None = None,
364
        resolve_name: str,
365
        required_version: str,
366
        conflicting_coordinate: Coordinate,
367
    ) -> None:
368
        source = f" from {requirement_source}" if requirement_source else ""
×
369
        msg = (
×
370
            f"The JVM resolve `{resolve_name}` contains a `jvm_artifact` for version {conflicting_coordinate.version} "
371
            f"of {subsystem}. This conflicts with version {required_version} which is the configured version "
372
            f"of {subsystem} for this resolve{source}. "
373
            "Please remove the `jvm_artifact` target with JVM coordinate "
374
            f"{conflicting_coordinate.to_coord_str()}, then re-run "
375
            f"`{bin_name()} generate-lockfiles --resolve={resolve_name}`"
376
        )
377
        super().__init__(msg)
×
378

379

380
class MissingRequiredJvmArtifactsInResolve(ValueError):
4✔
381
    def __init__(
4✔
382
        self,
383
        coordinates: Iterable[Coordinate | UnversionedCoordinate],
384
        *,
385
        subsystem: str,
386
        resolve_name: str,
387
        target_type: str,
388
    ) -> None:
389
        msg = (
×
390
            f"The JVM resolve `{resolve_name}` is missing one or more requirements for {subsystem}. "
391
            f"Since at least one JVM target type in this repository consumes a `{target_type}` target "
392
            "in this resolve, this resolve must contain `jvm_artifact` targets for each requirement of "
393
            f"{subsystem}.\n\n"
394
            "Please add the following `jvm_artifact` target(s) somewhere in the repository and re-run "
395
            f"`{bin_name()} generate-lockfiles --resolve={resolve_name}`:\n"
396
        )
397
        for coordinate in coordinates:
×
398
            if isinstance(coordinate, Coordinate):
×
399
                msg += (
×
400
                    "\njvm_artifact(\n"
401
                    f'  name="{coordinate.group}_{coordinate.artifact}",\n'
402
                    f'  group="{coordinate.group}",\n'
403
                    f'  artifact="{coordinate.artifact}",\n'
404
                    f'  version="{coordinate.version}",\n'
405
                    f'  resolve="{resolve_name}",\n'
406
                    ")\n"
407
                )
408
            elif isinstance(coordinate, UnversionedCoordinate):
×
409
                msg += (
×
410
                    "\njvm_artifact(\n"
411
                    f'  name="{coordinate.group}_{coordinate.artifact}",\n'
412
                    f'  group="{coordinate.group}",\n'
413
                    f'  artifact="{coordinate.artifact}",\n'
414
                    '  version="<your preferred runtime version>",\n'
415
                    f'  resolve="{resolve_name}",\n'
416
                    ")\n"
417
                )
418
        super().__init__(msg)
×
419

420

421
def find_jvm_artifacts_or_raise(
4✔
422
    required_coordinates: Iterable[Coordinate | UnversionedCoordinate],
423
    resolve: str,
424
    jvm_artifact_targets: AllJvmArtifactTargets,
425
    jvm: JvmSubsystem,
426
    *,
427
    subsystem: str,
428
    target_type: str,
429
    requirement_source: str | None = None,
430
) -> frozenset[Address]:
431
    remaining_coordinates: set[Coordinate | UnversionedCoordinate] = set(required_coordinates)
×
432

433
    addresses: set[Address] = set()
×
434
    for tgt in jvm_artifact_targets:
×
435
        if tgt[JvmResolveField].normalized_value(jvm) != resolve:
×
436
            continue
×
437

438
        artifact = ArtifactRequirement.from_jvm_artifact_target(tgt)
×
439
        found_coordinates: set[Coordinate | UnversionedCoordinate] = set()
×
440
        for coordinate in remaining_coordinates:
×
441
            if isinstance(coordinate, Coordinate):
×
442
                if (
×
443
                    artifact.coordinate.group != coordinate.group
444
                    or artifact.coordinate.artifact != coordinate.artifact
445
                ):
446
                    continue
×
447
                if artifact.coordinate.version != coordinate.version:
×
448
                    raise ConflictingJvmArtifactVersionInResolveError(
×
449
                        subsystem=subsystem,
450
                        requirement_source=requirement_source,
451
                        resolve_name=resolve,
452
                        required_version=coordinate.version,
453
                        conflicting_coordinate=artifact.coordinate,
454
                    )
455
            elif isinstance(coordinate, UnversionedCoordinate):
×
456
                if (
×
457
                    artifact.coordinate.group != coordinate.group
458
                    or artifact.coordinate.artifact != coordinate.artifact
459
                ):
460
                    continue
×
461

462
            found_coordinates.add(coordinate)
×
463

464
        if found_coordinates:
×
465
            remaining_coordinates.difference_update(found_coordinates)
×
466
            addresses.add(tgt.address)
×
467

468
    if remaining_coordinates:
×
469
        raise MissingRequiredJvmArtifactsInResolve(
×
470
            remaining_coordinates,
471
            subsystem=subsystem,
472
            resolve_name=resolve,
473
            target_type=target_type,
474
        )
475

476
    return frozenset(addresses)
×
477

478

479
def rules():
4✔
480
    return collect_rules()
4✔
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

© 2025 Coveralls, Inc