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

pantsbuild / pants / 20428083889

22 Dec 2025 09:43AM UTC coverage: 80.285% (-0.01%) from 80.296%
20428083889

Pull #21918

github

web-flow
Merge c895684e5 into 06f105be8
Pull Request #21918: [WIP] partition protobuf dependency inference by any "resolve-like" fields from plugins

191 of 263 new or added lines in 9 files covered. (72.62%)

44 existing lines in 2 files now uncovered.

78686 of 98008 relevant lines covered (80.29%)

3.65 hits per line

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

79.95
/src/python/pants/jvm/target_types.py
1
# Copyright 2020 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
13✔
5

6
import dataclasses
13✔
7
import re
13✔
8
import xml.etree.ElementTree as ET
13✔
9
from abc import ABC, ABCMeta, abstractmethod
13✔
10
from collections.abc import Callable, Iterable, Iterator
13✔
11
from dataclasses import dataclass
13✔
12
from typing import ClassVar
13✔
13

14
from pants.build_graph.build_file_aliases import BuildFileAliases
13✔
15
from pants.core.goals.generate_lockfiles import UnrecognizedResolveNamesError
13✔
16
from pants.core.goals.package import OutputPathField
13✔
17
from pants.core.goals.run import RestartableField, RunFieldSet, RunInSandboxBehavior
13✔
18
from pants.core.goals.test import TestExtraEnvVarsField, TestTimeoutField
13✔
19
from pants.core.target_types import (
13✔
20
    ResolveLikeField,
21
    ResolveLikeFieldToValueRequest,
22
    ResolveLikeFieldToValueResult,
23
)
24
from pants.core.util_rules.source_files import SourceFilesRequest, determine_source_files
13✔
25
from pants.engine.addresses import Address
13✔
26
from pants.engine.intrinsics import get_digest_contents
13✔
27
from pants.engine.rules import collect_rules, rule
13✔
28
from pants.engine.target import (
13✔
29
    COMMON_TARGET_FIELDS,
30
    AsyncFieldMixin,
31
    BoolField,
32
    Dependencies,
33
    DictStringToStringSequenceField,
34
    FieldDefaultFactoryRequest,
35
    FieldDefaultFactoryResult,
36
    GeneratedTargets,
37
    GenerateTargetsRequest,
38
    InvalidFieldException,
39
    InvalidTargetException,
40
    OptionalSingleSourceField,
41
    SequenceField,
42
    SingleSourceField,
43
    SpecialCasedDependencies,
44
    StringField,
45
    StringSequenceField,
46
    Target,
47
    TargetGenerator,
48
)
49
from pants.engine.unions import UnionMembership, UnionRule
13✔
50
from pants.jvm.resolve.coordinate import Coordinate
13✔
51
from pants.jvm.subsystems import JvmSubsystem
13✔
52
from pants.util.docutil import git_url
13✔
53
from pants.util.frozendict import FrozenDict
13✔
54
from pants.util.logging import LogLevel
13✔
55
from pants.util.strutil import bullet_list, help_text, pluralize, softwrap
13✔
56

57
# -----------------------------------------------------------------------------------------------
58
# Generic resolve support fields
59
# -----------------------------------------------------------------------------------------------
60

61

62
class JvmDependenciesField(Dependencies):
13✔
63
    pass
13✔
64

65

66
class JvmResolveLikeFieldToValueRequest(ResolveLikeFieldToValueRequest):
13✔
67
    pass
13✔
68

69

70
class JvmResolveField(StringField, AsyncFieldMixin, ResolveLikeField):
13✔
71
    alias = "resolve"
13✔
72
    required = False
13✔
73
    help = help_text(
13✔
74
        """
75
        The resolve from `[jvm].resolves` to use when compiling this target.
76

77
        If not defined, will default to `[jvm].default_resolve`.
78
        """
79
        # TODO: Document expectations for dependencies once we validate that.
80
    )
81

82
    def normalized_value(self, jvm_subsystem: JvmSubsystem) -> str:
13✔
83
        """Get the value after applying the default and validating that the key is recognized."""
84
        resolve = self.value or jvm_subsystem.default_resolve
×
85
        if resolve not in jvm_subsystem.resolves:
×
86
            raise UnrecognizedResolveNamesError(
×
87
                [resolve],
88
                jvm_subsystem.resolves.keys(),
89
                description_of_origin=f"the field `{self.alias}` in the target {self.address}",
90
            )
91
        return resolve
×
92

93
    def get_resolve_like_field_to_value_request(self) -> type[ResolveLikeFieldToValueRequest]:
13✔
NEW
94
        return JvmResolveLikeFieldToValueRequest
×
95

96

97
class JvmJdkField(StringField):
13✔
98
    alias = "jdk"
13✔
99
    required = False
13✔
100
    help = help_text(
13✔
101
        """
102
        The major version of the JDK that this target should be built with. If not defined,
103
        will default to `[jvm].default_source_jdk`.
104
        """
105
    )
106

107

108
class PrefixedJvmJdkField(JvmJdkField):
13✔
109
    alias = "jvm_jdk"
13✔
110

111

112
class PrefixedJvmResolveField(JvmResolveField):
13✔
113
    alias = "jvm_resolve"
13✔
114

115

116
# -----------------------------------------------------------------------------------------------
117
# Targets that can be called with `./pants run` or `experimental_run_in_sandbox`
118
# -----------------------------------------------------------------------------------------------
119
NO_MAIN_CLASS = "org.pantsbuild.meta.no.main.class"
13✔
120

121

122
class JvmMainClassNameField(StringField):
13✔
123
    alias = "main"
13✔
124
    required = False
13✔
125
    default = None
13✔
126
    help = help_text(
13✔
127
        """
128
        `.`-separated name of the JVM class containing the `main()` method to be called when
129
        executing this target. If not supplied, this will be calculated automatically, either by
130
        inspecting the existing manifest (for 3rd-party JARs), or by inspecting the classes inside
131
        the JAR, looking for a valid `main` method.  If a value cannot be calculated automatically,
132
        you must supply a value for `run` to succeed.
133
        """
134
    )
135

136

137
@dataclass(frozen=True)
13✔
138
class JvmRunnableSourceFieldSet(RunFieldSet):
13✔
139
    run_in_sandbox_behavior = RunInSandboxBehavior.RUN_REQUEST_HERMETIC
13✔
140
    jdk_version: JvmJdkField
13✔
141
    main_class: JvmMainClassNameField
13✔
142

143

144
@dataclass(frozen=True)
13✔
145
class GenericJvmRunRequest:
13✔
146
    """Allows the use of a generic rule to return a `RunRequest` based on the field set."""
147

148
    field_set: JvmRunnableSourceFieldSet
13✔
149

150

151
# -----------------------------------------------------------------------------------------------
152
# `jvm_artifact` targets
153
# -----------------------------------------------------------------------------------------------
154

155
_DEFAULT_PACKAGE_MAPPING_URL = git_url(
13✔
156
    "src/python/pants/jvm/dependency_inference/jvm_artifact_mappings.py"
157
)
158

159

160
class JvmArtifactGroupField(StringField):
13✔
161
    alias = "group"
13✔
162
    required = True
13✔
163
    value: str
13✔
164
    help = help_text(
13✔
165
        """
166
        The 'group' part of a Maven-compatible coordinate to a third-party JAR artifact.
167

168
        For the JAR coordinate `com.google.guava:guava:30.1.1-jre`, the group is `com.google.guava`.
169
        """
170
    )
171

172

173
class JvmArtifactArtifactField(StringField):
13✔
174
    alias = "artifact"
13✔
175
    required = True
13✔
176
    value: str
13✔
177
    help = help_text(
13✔
178
        """
179
        The 'artifact' part of a Maven-compatible coordinate to a third-party JAR artifact.
180

181
        For the JAR coordinate `com.google.guava:guava:30.1.1-jre`, the artifact is `guava`.
182
        """
183
    )
184

185

186
class JvmArtifactVersionField(StringField):
13✔
187
    alias = "version"
13✔
188
    required = True
13✔
189
    value: str
13✔
190
    help = help_text(
13✔
191
        """
192
        The 'version' part of a Maven-compatible coordinate to a third-party JAR artifact.
193

194
        For the JAR coordinate `com.google.guava:guava:30.1.1-jre`, the version is `30.1.1-jre`.
195
        """
196
    )
197

198

199
class JvmArtifactUrlField(StringField):
13✔
200
    alias = "url"
13✔
201
    required = False
13✔
202
    help = help_text(
13✔
203
        """
204
        A URL that points to the location of this artifact.
205

206
        If specified, Pants will not fetch this artifact from default Maven repositories, and
207
        will instead fetch the artifact from this URL. To use default maven
208
        repositories, do not set this value.
209

210
        Note that `file:` URLs are not supported. Instead, use the `jar` field for local
211
        artifacts.
212
        """
213
    )
214

215

216
class JvmArtifactJarSourceField(OptionalSingleSourceField):
13✔
217
    alias = "jar"
13✔
218
    expected_file_extensions = (".jar",)
13✔
219
    help = help_text(
13✔
220
        """
221
        A local JAR file that provides this artifact to the lockfile resolver, instead of a
222
        Maven repository.
223

224
        Path is relative to the BUILD file.
225

226
        Use the `url` field for remote artifacts.
227
        """
228
    )
229

230
    @classmethod
13✔
231
    def compute_value(cls, raw_value: str | None, address: Address) -> str | None:
13✔
232
        value_or_default = super().compute_value(raw_value, address)
1✔
233
        if value_or_default and value_or_default.startswith("file:"):
1✔
234
            raise InvalidFieldException(
×
235
                softwrap(
236
                    f"""
237
                    The `{cls.alias}` field does not support `file:` URLS, but the target
238
                    {address} sets the field to `{value_or_default}`.
239

240
                    Instead, use the `jar` field to specify the relative path to the local jar file.
241
                    """
242
                )
243
            )
244
        return value_or_default
1✔
245

246

247
class JvmArtifactPackagesField(StringSequenceField):
13✔
248
    alias = "packages"
13✔
249
    help = help_text(
13✔
250
        f"""
251
        The JVM packages this artifact provides for the purposes of dependency inference.
252

253
        For example, the JVM artifact `junit:junit` might provide `["org.junit.**"]`.
254

255
        Usually you can leave this field off. If unspecified, Pants will fall back to the
256
        `[java-infer].third_party_import_mapping`, then to a built in mapping
257
        ({_DEFAULT_PACKAGE_MAPPING_URL}), and then finally it will default to
258
        the normalized `group` of the artifact. For example, in the absence of any other mapping
259
        the artifact `io.confluent:common-config` would default to providing
260
        `["io.confluent.**"]`.
261

262
        The package path may be made recursive to match symbols in subpackages
263
        by adding `.**` to the end of the package path. For example, specify `["org.junit.**"]`
264
        to infer a dependency on the artifact for any file importing a symbol from `org.junit` or
265
        its subpackages.
266
        """
267
    )
268

269

270
class JvmArtifactForceVersionField(BoolField):
13✔
271
    alias = "force_version"
13✔
272
    default = False
13✔
273
    help = help_text(
13✔
274
        """
275
        Force artifact version during resolution.
276

277
        If set, pants will pass `--force-version` argument to `coursier fetch` for this artifact.
278
        """
279
    )
280

281

282
class JvmProvidesTypesField(StringSequenceField):
13✔
283
    alias = "experimental_provides_types"
13✔
284
    help = help_text(
13✔
285
        """
286
        Signals that the specified types should be fulfilled by these source files during
287
        dependency inference.
288

289
        This allows for specific types within packages that are otherwise inferred as
290
        belonging to `jvm_artifact` targets to be unambiguously inferred as belonging
291
        to this first-party source.
292

293
        If a given type is defined, at least one source file captured by this target must
294
        actually provide that symbol.
295
        """
296
    )
297

298

299
@dataclass(frozen=True)
13✔
300
class JvmArtifactExclusion:
13✔
301
    alias: ClassVar[str] = "jvm_exclude"
13✔
302
    help: ClassVar[str | Callable[[], str]] = help_text(
13✔
303
        """
304
        Exclude the given `artifact` and `group`, or all artifacts from the given `group`.
305
        """
306
    )
307

308
    group: str
13✔
309
    artifact: str | None = None
13✔
310

311
    def validate(self, _: Address) -> set[str]:
13✔
312
        return set()
1✔
313

314
    def to_coord_str(self) -> str:
13✔
315
        result = self.group
1✔
316
        if self.artifact:
1✔
317
            result += f":{self.artifact}"
×
318
        else:
319
            result += ":*"
1✔
320
        return result
1✔
321

322

323
def _jvm_artifact_exclusions_field_help(
13✔
324
    supported_exclusions: Callable[[], Iterable[type[JvmArtifactExclusion]]],
325
) -> str | Callable[[], str]:
326
    return help_text(
13✔
327
        lambda: f"""
328
        A list of exclusions for unversioned coordinates that should be excluded
329
        as dependencies when this artifact is resolved.
330

331
        This does not prevent this artifact from being included in the resolve as a dependency
332
        of other artifacts that depend on it, and is currently intended as a way to resolve
333
        version conflicts in complex resolves.
334

335
        Supported exclusions are:
336
        {bullet_list(f"`{exclusion.alias}`: {exclusion.help}" for exclusion in supported_exclusions())}
337
        """
338
    )
339

340

341
class JvmArtifactExclusionsField(SequenceField[JvmArtifactExclusion]):
13✔
342
    alias = "exclusions"
13✔
343
    help = _jvm_artifact_exclusions_field_help(
13✔
344
        lambda: JvmArtifactExclusionsField.supported_exclusion_types
345
    )
346

347
    supported_exclusion_types: ClassVar[tuple[type[JvmArtifactExclusion], ...]] = (
13✔
348
        JvmArtifactExclusion,
349
    )
350
    expected_element_type = JvmArtifactExclusion
13✔
351
    expected_type_description = "an iterable of JvmArtifactExclusionRule"
13✔
352

353
    @classmethod
13✔
354
    def compute_value(
13✔
355
        cls, raw_value: Iterable[JvmArtifactExclusion] | None, address: Address
356
    ) -> tuple[JvmArtifactExclusion, ...] | None:
357
        computed_value = super().compute_value(raw_value, address)
1✔
358

359
        if computed_value:
1✔
360
            errors: list[str] = []
1✔
361
            for exclusion_rule in computed_value:
1✔
362
                err = exclusion_rule.validate(address)
1✔
363
                if err:
1✔
364
                    errors.extend(err)
×
365

366
            if errors:
1✔
367
                raise InvalidFieldException(
×
368
                    softwrap(
369
                        f"""
370
                        Invalid value for `{JvmArtifactExclusionsField.alias}` field at target
371
                        {address}. Found following errors:
372

373
                        {bullet_list(errors)}
374
                        """
375
                    )
376
                )
377
        return computed_value
1✔
378

379

380
class JvmArtifactResolveField(JvmResolveField):
13✔
381
    help = help_text(
13✔
382
        """
383
        The resolve from `[jvm].resolves` that this artifact should be included in.
384

385
        If not defined, will default to `[jvm].default_resolve`.
386

387
        When generating a lockfile for a particular resolve via the `coursier-resolve` goal,
388
        it will include all artifacts that are declared compatible with that resolve. First-party
389
        targets like `java_source` and `scala_source` also declare which resolve they use
390
        via the `resolve` field; so, for your first-party code to use
391
        a particular `jvm_artifact` target, that artifact must be included in the resolve
392
        used by that code.
393
        """
394
    )
395

396

397
@dataclass(frozen=True)
13✔
398
class JvmArtifactFieldSet(JvmRunnableSourceFieldSet):
13✔
399
    group: JvmArtifactGroupField
13✔
400
    artifact: JvmArtifactArtifactField
13✔
401
    version: JvmArtifactVersionField
13✔
402
    packages: JvmArtifactPackagesField
13✔
403
    url: JvmArtifactUrlField
13✔
404
    force_version: JvmArtifactForceVersionField
13✔
405

406
    required_fields = (
13✔
407
        JvmArtifactGroupField,
408
        JvmArtifactArtifactField,
409
        JvmArtifactVersionField,
410
        JvmArtifactPackagesField,
411
        JvmArtifactForceVersionField,
412
    )
413

414

415
class JvmArtifactTarget(Target):
13✔
416
    alias = "jvm_artifact"
13✔
417
    core_fields = (
13✔
418
        *COMMON_TARGET_FIELDS,
419
        *JvmArtifactFieldSet.required_fields,
420
        JvmArtifactUrlField,  # TODO: should `JvmArtifactFieldSet` have an `all_fields` field?
421
        JvmArtifactJarSourceField,
422
        JvmArtifactResolveField,
423
        JvmArtifactExclusionsField,
424
        JvmJdkField,
425
        JvmMainClassNameField,
426
    )
427
    help = help_text(
13✔
428
        """
429
        A third-party JVM artifact, as identified by its Maven-compatible coordinate.
430

431
        That is, an artifact identified by its `group`, `artifact`, and `version` components.
432

433
        Each artifact is associated with one or more resolves (a logical name you give to a
434
        lockfile). For this artifact to be used by your first-party code, it must be
435
        associated with the resolve(s) used by that code. See the `resolve` field.
436
        """
437
    )
438

439
    def validate(self) -> None:
13✔
440
        if self[JvmArtifactJarSourceField].value and self[JvmArtifactUrlField].value:
1✔
441
            raise InvalidTargetException(
×
442
                f"You cannot specify both the `url` and `jar` fields, but both were set on the "
443
                f"`{self.alias}` target {self.address}."
444
            )
445

446

447
# -----------------------------------------------------------------------------------------------
448
# Generate `jvm_artifact` targets from pom.xml
449
# -----------------------------------------------------------------------------------------------
450

451

452
class PomXmlSourceField(SingleSourceField):
13✔
453
    default = "pom.xml"
13✔
454
    required = False
13✔
455

456

457
class JvmArtifactsPackageMappingField(DictStringToStringSequenceField):
13✔
458
    alias = "package_mapping"
13✔
459
    help = help_text(
13✔
460
        f"""
461
        A mapping of jvm artifacts to a list of the packages they provide.
462

463
        For example, `{{"com.google.guava:guava": ["com.google.common.**"]}}`.
464

465
        Any unspecified jvm artifacts will use a default. See the
466
        `{JvmArtifactPackagesField.alias}` field from the `{JvmArtifactTarget.alias}`
467
        target for more information.
468
        """
469
    )
470
    value: FrozenDict[str, tuple[str, ...]]
13✔
471
    default: ClassVar[FrozenDict[str, tuple[str, ...]] | None] = FrozenDict()
13✔
472

473
    @classmethod
13✔
474
    def compute_value(  # type: ignore[override]
13✔
475
        cls, raw_value: dict[str, Iterable[str]], address: Address
476
    ) -> FrozenDict[tuple[str, str], tuple[str, ...]]:
477
        value_or_default = super().compute_value(raw_value, address)
×
478
        assert value_or_default is not None
×
479
        return FrozenDict(
×
480
            {
481
                cls._parse_coord(coord): tuple(packages)
482
                for coord, packages in value_or_default.items()
483
            }
484
        )
485

486
    @classmethod
13✔
487
    def _parse_coord(cls, coord: str) -> tuple[str, str]:
13✔
488
        group, artifact = coord.split(":")
×
489
        return group, artifact
×
490

491

492
class JvmArtifactsTargetGenerator(TargetGenerator):
13✔
493
    alias = "jvm_artifacts"
13✔
494
    core_fields = (
13✔
495
        PomXmlSourceField,
496
        JvmArtifactsPackageMappingField,
497
        *COMMON_TARGET_FIELDS,
498
    )
499
    generated_target_cls = JvmArtifactTarget
13✔
500
    copied_fields = COMMON_TARGET_FIELDS
13✔
501
    moved_fields = (JvmArtifactResolveField,)
13✔
502
    help = help_text(
13✔
503
        """
504
        Generate a `jvm_artifact` target for each dependency in pom.xml file.
505
        """
506
    )
507

508

509
class GenerateFromPomXmlRequest(GenerateTargetsRequest):
13✔
510
    generate_from = JvmArtifactsTargetGenerator
13✔
511

512

513
@rule(
13✔
514
    desc=("Generate `jvm_artifact` targets from pom.xml"),
515
    level=LogLevel.DEBUG,
516
)
517
async def generate_from_pom_xml(
13✔
518
    request: GenerateFromPomXmlRequest,
519
    union_membership: UnionMembership,
520
) -> GeneratedTargets:
521
    generator = request.generator
×
522
    pom_xml = await determine_source_files(SourceFilesRequest([generator[PomXmlSourceField]]))
×
523
    files = await get_digest_contents(pom_xml.snapshot.digest)
×
524
    if not files:
×
525
        raise FileNotFoundError(f"pom.xml not found: {generator[PomXmlSourceField].value}")
×
526

527
    mapping = request.generator[JvmArtifactsPackageMappingField].value
×
528
    coordinates = parse_pom_xml(files[0].content, pom_xml_path=pom_xml.snapshot.files[0])
×
529
    targets = (
×
530
        JvmArtifactTarget(
531
            unhydrated_values={
532
                "group": coord.group,
533
                "artifact": coord.artifact,
534
                "version": coord.version,
535
                "packages": mapping.get((coord.group, coord.artifact)),
536
                **request.template,
537
            },
538
            address=request.template_address.create_generated(coord.artifact),
539
        )
540
        for coord in coordinates
541
    )
542
    return GeneratedTargets(request.generator, targets)
×
543

544

545
def parse_pom_xml(content: bytes, pom_xml_path: str) -> Iterator[Coordinate]:
13✔
546
    root = ET.fromstring(content.decode("utf-8"))
×
547
    match = re.match(r"^(\{.*\})project$", root.tag)
×
548
    if not match:
×
549
        raise ValueError(
×
550
            f"Unexpected root tag `{root.tag}` in {pom_xml_path}, expected tag `project`"
551
        )
552

553
    namespace = match.group(1)
×
554
    for dependency in root.iter(f"{namespace}dependency"):
×
555
        yield Coordinate(
×
556
            group=get_child_text(dependency, f"{namespace}groupId"),
557
            artifact=get_child_text(dependency, f"{namespace}artifactId"),
558
            version=get_child_text(dependency, f"{namespace}version"),
559
        )
560

561

562
def get_child_text(parent: ET.Element, child: str) -> str:
13✔
563
    tag = parent.find(child)
×
564
    if tag is None:
×
565
        raise ValueError(f"missing element: {child}")
×
566
    text = tag.text
×
567
    if text is None:
×
568
        raise ValueError(f"empty element: {child}")
×
569
    return text
×
570

571

572
# -----------------------------------------------------------------------------------------------
573
# JUnit test support field(s)
574
# -----------------------------------------------------------------------------------------------
575

576

577
class JunitTestSourceField(SingleSourceField, metaclass=ABCMeta):
13✔
578
    """A marker that indicates that a source field represents a JUnit test."""
579

580

581
class JunitTestTimeoutField(TestTimeoutField):
13✔
582
    pass
13✔
583

584

585
class JunitTestExtraEnvVarsField(TestExtraEnvVarsField):
13✔
586
    pass
13✔
587

588

589
# -----------------------------------------------------------------------------------------------
590
# JAR support fields
591
# -----------------------------------------------------------------------------------------------
592

593

594
class JvmRequiredMainClassNameField(JvmMainClassNameField):
13✔
595
    required = True
13✔
596
    default = None
13✔
597
    help = help_text(
13✔
598
        """
599
        `.`-separated name of the JVM class containing the `main()` method to be called when
600
        executing this JAR.
601
        """
602
    )
603

604

605
class JvmShadingRule(ABC):
13✔
606
    """Base class for defining JAR shading rules as valid aliases in BUILD files.
607

608
    Subclasses need to provide with an `alias` and a `help` message. The `alias` represents
609
    the name that will be used in BUILD files to instantiate the given subclass.
610

611
    Set the `help` class property with a description, which will be used in `./pants help`. For the
612
    best rendering, use soft wrapping (e.g. implicit string concatenation) within paragraphs, but
613
    hard wrapping (`\n`) to separate distinct paragraphs and/or lists.
614
    """
615

616
    alias: ClassVar[str]
13✔
617
    help: ClassVar[str | Callable[[], str]]
13✔
618

619
    @abstractmethod
13✔
620
    def encode(self) -> str:
13✔
621
        pass
×
622

623
    @abstractmethod
13✔
624
    def validate(self) -> set[str]:
13✔
625
        pass
×
626

627
    @staticmethod
13✔
628
    def _validate_field(value: str, *, name: str, invalid_chars: str) -> set[str]:
13✔
629
        errors = []
2✔
630
        for ch in invalid_chars:
2✔
631
            if ch in value:
2✔
632
                errors.append(f"`{name}` can not contain the character `{ch}`.")
1✔
633
        return set(errors)
2✔
634

635
    def __repr__(self) -> str:
13✔
636
        fields = [f"{fld.name}={repr(getattr(self, fld.name))}" for fld in dataclasses.fields(self)]  # type: ignore[arg-type]
×
637
        return f"{self.alias}({', '.join(fields)})"
×
638

639

640
@dataclass(frozen=True, repr=False)
13✔
641
class JvmShadingRenameRule(JvmShadingRule):
13✔
642
    alias = "shading_rename"
13✔
643
    help = "Renames all occurrences of the given `pattern` by the `replacement`."
13✔
644

645
    pattern: str
13✔
646
    replacement: str
13✔
647

648
    def encode(self) -> str:
13✔
649
        return f"rule {self.pattern} {self.replacement}"
×
650

651
    def validate(self) -> set[str]:
13✔
652
        errors: list[str] = []
2✔
653
        errors.extend(
2✔
654
            JvmShadingRule._validate_field(self.pattern, name="pattern", invalid_chars="/")
655
        )
656
        errors.extend(
2✔
657
            JvmShadingRule._validate_field(self.replacement, name="replacement", invalid_chars="/")
658
        )
659
        return set(errors)
2✔
660

661

662
@dataclass(frozen=True, repr=False)
13✔
663
class JvmShadingRelocateRule(JvmShadingRule):
13✔
664
    alias = "shading_relocate"
13✔
665
    help = help_text(
13✔
666
        """
667
        Relocates the classes under the given `package` into the new package name.
668
        The default target package is `__shaded_by_pants__` if none provided in
669
        the `into` parameter.
670
        """
671
    )
672

673
    package: str
13✔
674
    into: str | None = None
13✔
675

676
    def encode(self) -> str:
13✔
677
        if not self.into:
×
678
            target_suffix = "__shaded_by_pants__"
×
679
        else:
680
            target_suffix = self.into
×
681
        return f"rule {self.package}.** {target_suffix}.@1"
×
682

683
    def validate(self) -> set[str]:
13✔
684
        errors: list[str] = []
2✔
685
        errors.extend(
2✔
686
            JvmShadingRule._validate_field(self.package, name="package", invalid_chars="/*")
687
        )
688
        if self.into:
2✔
689
            errors.extend(
2✔
690
                JvmShadingRule._validate_field(self.into, name="into", invalid_chars="/*")
691
            )
692
        return set(errors)
2✔
693

694

695
@dataclass(frozen=True, repr=False)
13✔
696
class JvmShadingZapRule(JvmShadingRule):
13✔
697
    alias = "shading_zap"
13✔
698
    help = "Removes from the final artifact the occurrences of the `pattern`."
13✔
699

700
    pattern: str
13✔
701

702
    def encode(self) -> str:
13✔
703
        return f"zap {self.pattern}"
×
704

705
    def validate(self) -> set[str]:
13✔
706
        return JvmShadingRule._validate_field(self.pattern, name="pattern", invalid_chars="/")
1✔
707

708

709
@dataclass(frozen=True, repr=False)
13✔
710
class JvmShadingKeepRule(JvmShadingRule):
13✔
711
    alias = "shading_keep"
13✔
712
    help = help_text(
13✔
713
        """
714
        Keeps in the final artifact the occurrences of the `pattern`
715
        (and removes anything else).
716
        """
717
    )
718

719
    pattern: str
13✔
720

721
    def encode(self) -> str:
13✔
722
        return f"keep {self.pattern}"
×
723

724
    def validate(self) -> set[str]:
13✔
725
        return JvmShadingRule._validate_field(self.pattern, name="pattern", invalid_chars="/")
1✔
726

727

728
JVM_SHADING_RULE_TYPES: list[type[JvmShadingRule]] = [
13✔
729
    JvmShadingRelocateRule,
730
    JvmShadingRenameRule,
731
    JvmShadingZapRule,
732
    JvmShadingKeepRule,
733
]
734

735

736
def _shading_rules_field_help(intro: str) -> str:
13✔
737
    return softwrap(
13✔
738
        f"""
739
        {intro}
740

741
        There are {pluralize(len(JVM_SHADING_RULE_TYPES), "possible shading rule")} available,
742
        which are as follows:
743
        {bullet_list([f"`{rule.alias}`: {rule.help}" for rule in JVM_SHADING_RULE_TYPES])}
744

745
        When defining shading rules, just add them in this field using the previously listed rule
746
        alias and passing along the required parameters.
747
        """
748
    )
749

750

751
def _shading_validate_rules(shading_rules: Iterable[JvmShadingRule]) -> set[str]:
13✔
752
    validation_errors = []
2✔
753
    for shading_rule in shading_rules:
2✔
754
        found_errors = shading_rule.validate()
2✔
755
        if found_errors:
2✔
756
            validation_errors.append(
1✔
757
                "\n".join(
758
                    [
759
                        f"In rule `{shading_rule.alias}`:",
760
                        bullet_list(found_errors),
761
                        "",
762
                    ]
763
                )
764
            )
765
    return set(validation_errors)
2✔
766

767

768
class JvmShadingRulesField(SequenceField[JvmShadingRule], metaclass=ABCMeta):
13✔
769
    alias = "shading_rules"
13✔
770
    required = False
13✔
771
    expected_element_type = JvmShadingRule
13✔
772
    expected_type_description = "an iterable of JvmShadingRule"
13✔
773

774
    @classmethod
13✔
775
    def compute_value(
13✔
776
        cls, raw_value: Iterable[JvmShadingRule] | None, address: Address
777
    ) -> tuple[JvmShadingRule, ...] | None:
778
        computed_value = super().compute_value(raw_value, address)
×
779

780
        if computed_value:
×
781
            validation_errors = _shading_validate_rules(computed_value)
×
782
            if validation_errors:
×
783
                raise InvalidFieldException(
×
784
                    "\n".join(
785
                        [
786
                            f"Invalid shading rules assigned to `{cls.alias}` field in target {address}:\n",
787
                            *validation_errors,
788
                        ]
789
                    )
790
                )
791

792
        return computed_value
×
793

794

795
# -----------------------------------------------------------------------------------------------
796
# `deploy_jar` target
797
# -----------------------------------------------------------------------------------------------
798

799

800
@dataclass(frozen=True)
13✔
801
class DeployJarDuplicateRule:
13✔
802
    alias: ClassVar[str] = "duplicate_rule"
13✔
803
    valid_actions: ClassVar[tuple[str, ...]] = ("skip", "replace", "concat", "concat_text", "throw")
13✔
804

805
    pattern: str
13✔
806
    action: str
13✔
807

808
    def validate(self) -> str | None:
13✔
809
        if self.action not in DeployJarDuplicateRule.valid_actions:
×
810
            return softwrap(
×
811
                f"""
812
                Value '{self.action}' for `action` associated with pattern
813
                '{self.pattern}' is not valid.
814

815
                It must be one of {list(DeployJarDuplicateRule.valid_actions)}.
816
                """
817
            )
818
        return None
×
819

820
    def __repr__(self) -> str:
13✔
821
        return f"{self.alias}(pattern='{self.pattern}', action='{self.action}')"
×
822

823

824
class DeployJarDuplicatePolicyField(SequenceField[DeployJarDuplicateRule]):
13✔
825
    alias = "duplicate_policy"
13✔
826
    help = help_text(
13✔
827
        f"""
828
        A list of the rules to apply when duplicate file entries are found in the final
829
        assembled JAR file.
830

831
        When defining a duplicate policy, just add `duplicate_rule` directives to this
832
        field as follows:
833

834
        Example:
835

836
            duplicate_policy=[
837
                duplicate_rule(pattern="^META-INF/services", action="concat_text"),
838
                duplicate_rule(pattern="^reference\\.conf", action="concat_text"),
839
                duplicate_rule(pattern="^org/apache/commons", action="throw"),
840
            ]
841

842
        Where:
843

844
        * The `pattern` field is treated as a regular expression
845
        * The `action` field must be one of `{list(DeployJarDuplicateRule.valid_actions)}`.
846

847
        Note that the order in which the rules are listed is relevant.
848
        """
849
    )
850
    required = False
13✔
851

852
    expected_element_type = DeployJarDuplicateRule
13✔
853
    expected_type_description = "a list of JAR duplicate rules"
13✔
854

855
    default = (
13✔
856
        DeployJarDuplicateRule(pattern="^META-INF/services/", action="concat_text"),
857
        DeployJarDuplicateRule(pattern="^META-INF/LICENSE", action="skip"),
858
    )
859

860
    @classmethod
13✔
861
    def compute_value(
13✔
862
        cls, raw_value: Iterable[DeployJarDuplicateRule] | None, address: Address
863
    ) -> tuple[DeployJarDuplicateRule, ...] | None:
864
        value = super().compute_value(raw_value, address)
×
865
        if value:
×
866
            errors = []
×
867
            for duplicate_rule in value:
×
868
                err = duplicate_rule.validate()
×
869
                if err:
×
870
                    errors.append(err)
×
871

872
            if errors:
×
873
                raise InvalidFieldException(
×
874
                    softwrap(
875
                        f"""
876
                        Invalid value for `{DeployJarDuplicatePolicyField.alias}` field at target:
877
                        {address}. Found following errors:
878

879
                        {bullet_list(errors)}
880
                        """
881
                    )
882
                )
883
        return value
×
884

885
    def value_or_default(self) -> tuple[DeployJarDuplicateRule, ...]:
13✔
886
        if self.value is not None:
×
887
            return self.value
×
888
        return self.default
×
889

890

891
class DeployJarShadingRulesField(JvmShadingRulesField):
13✔
892
    help = _shading_rules_field_help("Shading rules to be applied to the final JAR artifact.")
13✔
893

894

895
class DeployJarExcludeFilesField(StringSequenceField):
13✔
896
    alias = "exclude_files"
13✔
897
    help = help_text(
13✔
898
        """
899
        A list of patterns to exclude from the final jar.
900
        """
901
    )
902

903

904
class DeployJarTarget(Target):
13✔
905
    alias = "deploy_jar"
13✔
906
    core_fields = (
13✔
907
        *COMMON_TARGET_FIELDS,
908
        RestartableField,
909
        OutputPathField,
910
        JvmDependenciesField,
911
        JvmRequiredMainClassNameField,
912
        JvmJdkField,
913
        JvmResolveField,
914
        DeployJarDuplicatePolicyField,
915
        DeployJarShadingRulesField,
916
        DeployJarExcludeFilesField,
917
    )
918
    help = help_text(
13✔
919
        """
920
        A `jar` file with first and third-party code bundled for deploys.
921

922
        The JAR will contain class files for both first-party code and
923
        third-party dependencies, all in a common directory structure.
924
        """
925
    )
926

927

928
# -----------------------------------------------------------------------------------------------
929
# `jvm_war` targets
930
# -----------------------------------------------------------------------------------------------
931

932

933
class JvmWarDependenciesField(Dependencies):
13✔
934
    pass
13✔
935

936

937
class JvmWarDescriptorAddressField(SingleSourceField):
13✔
938
    alias = "descriptor"
13✔
939
    default = "web.xml"
13✔
940
    help = "Path to a file containing the descriptor (i.e., `web.xml`) for this WAR file. Defaults to `web.xml`."
13✔
941

942

943
class JvmWarContentField(SpecialCasedDependencies):
13✔
944
    alias = "content"
13✔
945
    help = help_text(
13✔
946
        """
947
        A list of addresses to `resources` and `files` targets with content to place in the
948
        document root of this WAR file.
949
        """
950
    )
951

952

953
class JvmWarShadingRulesField(JvmShadingRulesField):
13✔
954
    help = _shading_rules_field_help(
13✔
955
        "Shading rules to be applied to the individual JAR artifacts embedded in the `WEB-INF/lib` folder."
956
    )
957

958

959
class JvmWarTarget(Target):
13✔
960
    alias = "jvm_war"
13✔
961
    core_fields = (
13✔
962
        *COMMON_TARGET_FIELDS,
963
        JvmResolveField,
964
        JvmWarContentField,
965
        JvmWarDependenciesField,
966
        JvmWarDescriptorAddressField,
967
        JvmWarShadingRulesField,
968
        OutputPathField,
969
    )
970
    help = help_text(
13✔
971
        """
972
        A JSR 154 "web application archive" (or "war") with first-party and third-party code bundled for
973
        deploys in Java Servlet containers.
974
        """
975
    )
976

977

978
# -----------------------------------------------------------------------------------------------
979
# Dynamic Field defaults
980
# -----------------------------------------------------------------------------------------------#
981

982

983
class JvmResolveFieldDefaultFactoryRequest(FieldDefaultFactoryRequest):
13✔
984
    field_type = JvmResolveField
13✔
985

986

987
@rule
13✔
988
async def jvm_resolve_field_default_factory(
13✔
989
    request: JvmResolveFieldDefaultFactoryRequest,
990
    jvm: JvmSubsystem,
991
) -> FieldDefaultFactoryResult:
992
    return FieldDefaultFactoryResult(lambda f: f.normalized_value(jvm))
×
993

994

995
@rule
13✔
996
async def jvm_resolve_like_field_to_value_request(
13✔
997
    request: JvmResolveLikeFieldToValueRequest, jvm: JvmSubsystem
998
) -> ResolveLikeFieldToValueResult:
NEW
999
    resolve = request.target[JvmResolveField].normalized_value(jvm)
×
NEW
1000
    return ResolveLikeFieldToValueResult(value=resolve)
×
1001

1002

1003
def rules():
13✔
1004
    return [
11✔
1005
        *collect_rules(),
1006
        UnionRule(GenerateTargetsRequest, GenerateFromPomXmlRequest),
1007
        UnionRule(FieldDefaultFactoryRequest, JvmResolveFieldDefaultFactoryRequest),
1008
        UnionRule(ResolveLikeFieldToValueRequest, JvmResolveLikeFieldToValueRequest),
1009
    ]
1010

1011

1012
def build_file_aliases():
13✔
1013
    return BuildFileAliases(
×
1014
        objects={
1015
            JvmArtifactExclusion.alias: JvmArtifactExclusion,
1016
            DeployJarDuplicateRule.alias: DeployJarDuplicateRule,
1017
            **{rule.alias: rule for rule in JVM_SHADING_RULE_TYPES},
1018
        }
1019
    )
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