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

pantsbuild / pants / 22285099215

22 Feb 2026 08:52PM UTC coverage: 75.854% (-17.1%) from 92.936%
22285099215

Pull #23121

github

web-flow
Merge c7299df9c into ba8359840
Pull Request #23121: fix issue with optional fields in dependency validator

28 of 29 new or added lines in 2 files covered. (96.55%)

11174 existing lines in 400 files now uncovered.

53694 of 70786 relevant lines covered (75.85%)

1.88 hits per line

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

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

4
from __future__ import annotations
4✔
5

6
import dataclasses
4✔
7
import logging
4✔
8
import os
4✔
9
import re
4✔
10
import shlex
4✔
11
import textwrap
4✔
12
from collections.abc import Iterable, Mapping
4✔
13
from dataclasses import dataclass
4✔
14
from enum import Enum
4✔
15
from typing import ClassVar
4✔
16

17
from pants.core.environments.target_types import EnvironmentTarget
4✔
18
from pants.core.util_rules.system_binaries import BashBinary, LnBinary
4✔
19
from pants.engine.fs import CreateDigest, Digest, FileContent, FileDigest, MergeDigests
4✔
20
from pants.engine.intrinsics import create_digest, execute_process, merge_digests
4✔
21
from pants.engine.process import Process, ProcessCacheScope
4✔
22
from pants.engine.rules import collect_rules, implicitly, rule
4✔
23
from pants.engine.target import CoarsenedTarget
4✔
24
from pants.jvm.compile import ClasspathEntry
4✔
25
from pants.jvm.resolve.coordinate import Coordinate, Coordinates
4✔
26
from pants.jvm.resolve.coursier_fetch import CoursierLockfileEntry, coursier_fetch_one_coord
4✔
27
from pants.jvm.resolve.coursier_setup import Coursier
4✔
28
from pants.jvm.subsystems import JvmSubsystem
4✔
29
from pants.jvm.target_types import JvmJdkField
4✔
30
from pants.option.global_options import GlobalOptions
4✔
31
from pants.util.docutil import bin_name
4✔
32
from pants.util.frozendict import FrozenDict
4✔
33
from pants.util.logging import LogLevel
4✔
34
from pants.util.meta import classproperty
4✔
35
from pants.util.strutil import fmt_memory_size, softwrap
4✔
36

37
logger = logging.getLogger(__name__)
4✔
38

39

40
@dataclass(frozen=True)
4✔
41
class Nailgun:
4✔
42
    classpath_entry: ClasspathEntry
4✔
43

44

45
class DefaultJdk(Enum):
4✔
46
    SYSTEM = "system"
4✔
47
    SOURCE_DEFAULT = "source_default"
4✔
48

49

50
@dataclass(frozen=True)
4✔
51
class JdkRequest:
4✔
52
    """Request for a JDK with a specific major version, or a default (`--jvm-jdk` or System)."""
53

54
    version: str | DefaultJdk
4✔
55

56
    @classproperty
4✔
57
    def SYSTEM(cls) -> JdkRequest:
4✔
UNCOV
58
        return JdkRequest(DefaultJdk.SYSTEM)
×
59

60
    @classproperty
4✔
61
    def SOURCE_DEFAULT(cls) -> JdkRequest:
4✔
62
        return JdkRequest(DefaultJdk.SOURCE_DEFAULT)
3✔
63

64
    @staticmethod
4✔
65
    def from_field(field: JvmJdkField) -> JdkRequest:
4✔
66
        version = field.value
3✔
67
        if version == "system":
3✔
68
            return JdkRequest.SYSTEM
×
69
        return JdkRequest(version) if version is not None else JdkRequest.SOURCE_DEFAULT
3✔
70

71
    @staticmethod
4✔
72
    def from_target(target: CoarsenedTarget) -> JdkRequest:
4✔
73
        fields = [t[JvmJdkField] for t in target.members if t.has_field(JvmJdkField)]
3✔
74

75
        if not fields:
3✔
76
            raise ValueError(
×
77
                f"Cannot construct a JDK request for {target}, since none of its "
78
                f"members have a `{JvmJdkField.alias}=` field:\n{target.bullet_list()}"
79
            )
80

81
        field = fields[0]
3✔
82
        if not all(f.value == field.value for f in fields):
3✔
83
            values = {f.value for f in fields}
×
84
            raise ValueError(
×
85
                f"The members of {target} had mismatched values of the "
86
                f"`{JvmJdkField.alias}=` field ({values}):\n{target.bullet_list()}"
87
            )
88

89
        return JdkRequest.from_field(field)
3✔
90

91

92
@dataclass(frozen=True)
4✔
93
class JdkEnvironment:
4✔
94
    _digest: Digest
4✔
95
    nailgun_jar: str
4✔
96
    coursier: Coursier
4✔
97
    jre_major_version: int
4✔
98
    global_jvm_options: tuple[str, ...]
4✔
99
    java_home_command: str
4✔
100

101
    bin_dir: ClassVar[str] = "__jdk"
4✔
102
    jdk_preparation_script: ClassVar[str] = f"{bin_dir}/jdk.sh"
4✔
103
    java_home: ClassVar[str] = "__java_home"
4✔
104

105
    def args(
4✔
106
        self, bash: BashBinary, classpath_entries: Iterable[str], chroot: str | None = None
107
    ) -> tuple[str, ...]:
108
        def in_chroot(path: str) -> str:
4✔
109
            if not chroot:
4✔
110
                return path
4✔
111
            return os.path.join(chroot, path)
×
112

113
        return (
4✔
114
            bash.path,
115
            in_chroot(self.jdk_preparation_script),
116
            f"{self.java_home}/bin/java",
117
            "-cp",
118
            ":".join([in_chroot(self.nailgun_jar), *classpath_entries]),
119
        )
120

121
    @property
4✔
122
    def env(self) -> dict[str, str]:
4✔
123
        return self.coursier.env
4✔
124

125
    @property
4✔
126
    def append_only_caches(self) -> dict[str, str]:
4✔
127
        return self.coursier.append_only_caches
4✔
128

129
    @property
4✔
130
    def immutable_input_digests(self) -> dict[str, Digest]:
4✔
131
        return {**self.coursier.immutable_input_digests, self.bin_dir: self._digest}
4✔
132

133

134
@dataclass(frozen=True)
4✔
135
class InternalJdk(JdkEnvironment):
4✔
136
    """The JDK configured for internal Pants usage, rather than for matching source compatibility.
137

138
    The InternalJdk should only be used in situations where no classfiles are required for a user's
139
    firstparty or thirdparty code (such as for codegen, or analysis of source files).
140
    """
141

142
    @classmethod
4✔
143
    def from_jdk_environment(cls, env: JdkEnvironment) -> InternalJdk:
4✔
144
        return cls(
4✔
145
            env._digest,
146
            env.nailgun_jar,
147
            env.coursier,
148
            env.jre_major_version,
149
            env.global_jvm_options,
150
            env.java_home_command,
151
        )
152

153

154
VERSION_REGEX = re.compile(r"version \"(.+?)\"")
4✔
155

156

157
def parse_jre_major_version(version_lines: str) -> int | None:
4✔
158
    for line in version_lines.splitlines():
4✔
159
        m = VERSION_REGEX.search(line)
4✔
160
        if m:
4✔
161
            major_version, _, _ = m[1].partition(".")
4✔
162
            return int(major_version)
4✔
163
    return None
×
164

165

166
@rule
4✔
167
async def fetch_nailgun() -> Nailgun:
4✔
168
    nailgun = await coursier_fetch_one_coord(
4✔
169
        CoursierLockfileEntry(
170
            coord=Coordinate.from_coord_str("com.martiansoftware:nailgun-server:0.9.1"),
171
            file_name="com.martiansoftware_nailgun-server_0.9.1.jar",
172
            direct_dependencies=Coordinates(),
173
            dependencies=Coordinates(),
174
            file_digest=FileDigest(
175
                fingerprint="4518faa6bf4bd26fccdc4d85e1625dc679381a08d56872d8ad12151dda9cef25",
176
                serialized_bytes_length=32927,
177
            ),
178
        )
179
    )
180

181
    return Nailgun(nailgun)
4✔
182

183

184
@rule
4✔
185
async def prepare_jdk_environment(
4✔
186
    jvm: JvmSubsystem,
187
    jvm_env_aware: JvmSubsystem.EnvironmentAware,
188
    coursier: Coursier,
189
    nailgun_: Nailgun,
190
    bash: BashBinary,
191
    ln: LnBinary,
192
    request: JdkRequest,
193
    env_target: EnvironmentTarget,
194
) -> JdkEnvironment:
195
    nailgun = nailgun_.classpath_entry
4✔
196

197
    version = request.version
4✔
198
    if version == DefaultJdk.SOURCE_DEFAULT:
4✔
199
        version = jvm.jdk
3✔
200

201
    # TODO: add support for system JDKs with specific version
202
    if version is DefaultJdk.SYSTEM:
4✔
203
        coursier_jdk_option = "--system-jvm"
×
204
    else:
205
        coursier_jdk_option = f"--jvm={version}"
4✔
206

207
    if not coursier.jvm_index:
4✔
208
        coursier_options = ["java-home", "--quiet", "--quiet", coursier_jdk_option]
4✔
209
    else:
UNCOV
210
        jvm_index_option = f"--jvm-index={coursier.jvm_index}"
×
UNCOV
211
        coursier_options = [
×
212
            "java-home",
213
            "--quiet",
214
            "--quiet",
215
            jvm_index_option,
216
            coursier_jdk_option,
217
        ]
218

219
    # TODO(#16104) This argument re-writing code should use the native {chroot} support.
220
    # See also `run` for other argument re-writing code.
221
    def prefixed(arg: str) -> str:
4✔
222
        quoted = shlex.quote(arg)
4✔
223
        if arg.startswith("__"):
4✔
224
            return f"${{PANTS_INTERNAL_ABSOLUTE_PREFIX}}{quoted}"
4✔
225
        else:
226
            return quoted
4✔
227

228
    optionally_prefixed_coursier_args = [prefixed(arg) for arg in coursier.args(coursier_options)]
4✔
229
    # NB: We `set +e` in the subshell to ensure that it exits as well.
230
    #  see https://unix.stackexchange.com/a/23099
231
    java_home_command = " ".join(("set +e;", *optionally_prefixed_coursier_args))
4✔
232

233
    env = {
4✔
234
        "PANTS_INTERNAL_ABSOLUTE_PREFIX": "",
235
        **coursier.env,
236
    }
237

238
    java_version_result = await execute_process(
4✔
239
        Process(
240
            argv=(
241
                bash.path,
242
                "-c",
243
                f"$({java_home_command})/bin/java -version",
244
            ),
245
            append_only_caches=coursier.append_only_caches,
246
            immutable_input_digests=coursier.immutable_input_digests,
247
            env=env,
248
            description=f"Ensure download of JDK {coursier_jdk_option}.",
249
            cache_scope=env_target.executable_search_path_cache_scope(),
250
            level=LogLevel.DEBUG,
251
        ),
252
        **implicitly(),
253
    )
254

255
    if java_version_result.exit_code != 0:
4✔
256
        raise ValueError(
×
257
            f"Failed to locate Java for JDK `{version}`:\n"
258
            f"{java_version_result.stderr.decode('utf-8')}"
259
        )
260

261
    java_version = java_version_result.stderr.decode("utf-8").strip()
4✔
262
    jre_major_version = parse_jre_major_version(java_version)
4✔
263
    if not jre_major_version:
4✔
264
        raise ValueError(
×
265
            "Pants was unable to parse the output of `java -version` for JDK "
266
            f"`{request.version}`. Please open an issue at "
267
            "https://github.com/pantsbuild/pants/issues/new/choose with the following output:\n\n"
268
            f"{java_version}"
269
        )
270

271
    # TODO: Locate `ln`.
272
    version_comment = "\n".join(f"# {line}" for line in java_version.splitlines())
4✔
273
    ln_path = shlex.quote(ln.path)
4✔
274
    jdk_preparation_script = textwrap.dedent(  # noqa: PNT20
4✔
275
        f"""\
276
        # pants javac script using Coursier {coursier_jdk_option}. `java -version`:"
277
        {version_comment}
278
        set -eu
279

280
        {ln_path} -s "$({java_home_command})" "${{PANTS_INTERNAL_ABSOLUTE_PREFIX}}{JdkEnvironment.java_home}"
281
        exec "$@"
282
        """
283
    )
284
    jdk_preparation_script_digest = await create_digest(
4✔
285
        CreateDigest(
286
            [
287
                FileContent(
288
                    os.path.basename(JdkEnvironment.jdk_preparation_script),
289
                    jdk_preparation_script.encode("utf-8"),
290
                    is_executable=True,
291
                ),
292
            ]
293
        ),
294
    )
295

296
    return JdkEnvironment(
4✔
297
        _digest=await merge_digests(
298
            MergeDigests(
299
                [
300
                    jdk_preparation_script_digest,
301
                    nailgun.digest,
302
                ]
303
            ),
304
        ),
305
        global_jvm_options=jvm_env_aware.global_options,
306
        nailgun_jar=os.path.join(JdkEnvironment.bin_dir, nailgun.filenames[0]),
307
        coursier=coursier,
308
        jre_major_version=jre_major_version,
309
        java_home_command=java_home_command,
310
    )
311

312

313
@rule
4✔
314
async def internal_jdk(jvm: JvmSubsystem) -> InternalJdk:
4✔
315
    """Creates a `JdkEnvironment` object based on the JVM subsystem options.
316

317
    This is used for providing a predictable JDK version for Pants' internal usage rather than for
318
    matching compatibility with source files (e.g. compilation/testing).
319
    """
320

321
    request = JdkRequest(jvm.tool_jdk) if jvm.tool_jdk is not None else JdkRequest.SYSTEM
4✔
322
    env = await prepare_jdk_environment(**implicitly({request: JdkRequest}))
4✔
323
    return InternalJdk.from_jdk_environment(env)
4✔
324

325

326
@dataclass(frozen=True)
4✔
327
class JvmProcess:
4✔
328
    jdk: JdkEnvironment
4✔
329
    argv: tuple[str, ...]
4✔
330
    classpath_entries: tuple[str, ...]
4✔
331
    input_digest: Digest
4✔
332
    description: str = dataclasses.field(compare=False)
4✔
333
    level: LogLevel
4✔
334
    extra_jvm_options: tuple[str, ...]
4✔
335
    extra_nailgun_keys: tuple[str, ...]
4✔
336
    output_files: tuple[str, ...]
4✔
337
    output_directories: tuple[str, ...]
4✔
338
    timeout_seconds: int | float | None
4✔
339
    extra_immutable_input_digests: FrozenDict[str, Digest]
4✔
340
    extra_env: FrozenDict[str, str]
4✔
341
    cache_scope: ProcessCacheScope | None
4✔
342
    use_nailgun: bool
4✔
343
    remote_cache_speculation_delay: int | None
4✔
344

345
    def __init__(
4✔
346
        self,
347
        jdk: JdkEnvironment,
348
        argv: Iterable[str],
349
        classpath_entries: Iterable[str],
350
        input_digest: Digest,
351
        description: str,
352
        level: LogLevel = LogLevel.INFO,
353
        extra_jvm_options: Iterable[str] | None = None,
354
        extra_nailgun_keys: Iterable[str] | None = None,
355
        output_files: Iterable[str] | None = None,
356
        output_directories: Iterable[str] | None = None,
357
        extra_immutable_input_digests: Mapping[str, Digest] | None = None,
358
        extra_env: Mapping[str, str] | None = None,
359
        timeout_seconds: int | float | None = None,
360
        cache_scope: ProcessCacheScope | None = None,
361
        use_nailgun: bool = True,
362
        remote_cache_speculation_delay: int | None = None,
363
    ):
364
        object.__setattr__(self, "jdk", jdk)
4✔
365
        object.__setattr__(self, "argv", tuple(argv))
4✔
366
        object.__setattr__(self, "classpath_entries", tuple(classpath_entries))
4✔
367
        object.__setattr__(self, "input_digest", input_digest)
4✔
368
        object.__setattr__(self, "description", description)
4✔
369
        object.__setattr__(self, "level", level)
4✔
370
        object.__setattr__(self, "extra_jvm_options", tuple(extra_jvm_options or ()))
4✔
371
        object.__setattr__(self, "extra_nailgun_keys", tuple(extra_nailgun_keys or ()))
4✔
372
        object.__setattr__(self, "output_files", tuple(output_files or ()))
4✔
373
        object.__setattr__(self, "output_directories", tuple(output_directories or ()))
4✔
374
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
4✔
375
        object.__setattr__(self, "cache_scope", cache_scope)
4✔
376
        object.__setattr__(
4✔
377
            self, "extra_immutable_input_digests", FrozenDict(extra_immutable_input_digests or {})
378
        )
379
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
4✔
380
        object.__setattr__(self, "use_nailgun", use_nailgun)
4✔
381
        object.__setattr__(self, "remote_cache_speculation_delay", remote_cache_speculation_delay)
4✔
382

383
        self.__post_init__()
4✔
384

385
    def __post_init__(self):
4✔
386
        if not self.use_nailgun and self.extra_nailgun_keys:
4✔
387
            raise AssertionError(
×
388
                "`JvmProcess` specified nailgun keys, but has `use_nailgun=False`. Either "
389
                "specify `extra_nailgun_keys=None` or `use_nailgun=True`."
390
            )
391

392

393
_JVM_HEAP_SIZE_UNITS = ["", "k", "m", "g"]
4✔
394

395

396
@rule
4✔
397
async def jvm_process(
4✔
398
    bash: BashBinary, request: JvmProcess, jvm: JvmSubsystem, global_options: GlobalOptions
399
) -> Process:
400
    jdk = request.jdk
4✔
401

402
    immutable_input_digests = {
4✔
403
        **jdk.immutable_input_digests,
404
        **request.extra_immutable_input_digests,
405
    }
406
    env = {
4✔
407
        "PANTS_INTERNAL_ABSOLUTE_PREFIX": "",
408
        **jdk.env,
409
        **request.extra_env,
410
    }
411

412
    def valid_jvm_opt(opt: str) -> str:
4✔
413
        if opt.startswith("-Xmx"):
2✔
UNCOV
414
            raise ValueError(
×
415
                softwrap(
416
                    f"""
417
                    Invalid value for JVM options: {opt}.
418

419
                    For setting a maximum heap size for the JVM child processes, use
420
                    `[GLOBAL].process_per_child_memory_usage` option instead.
421

422
                    Run `{bin_name()} help-advanced global` for more information.
423
                    """
424
                )
425
            )
426
        return opt
2✔
427

428
    max_heap_size = fmt_memory_size(
4✔
429
        global_options.process_per_child_memory_usage, units=_JVM_HEAP_SIZE_UNITS
430
    )
431
    jvm_user_options = [*jdk.global_jvm_options, *request.extra_jvm_options]
4✔
432
    jvm_options = [
4✔
433
        f"-Xmx{max_heap_size}",
434
        *[valid_jvm_opt(opt) for opt in jvm_user_options],
435
    ]
436

437
    use_nailgun = []
4✔
438
    if request.use_nailgun:
4✔
439
        use_nailgun = [*jdk.immutable_input_digests, *request.extra_nailgun_keys]
4✔
440

441
    remote_cache_speculation_delay_millis = 0
4✔
442
    if request.remote_cache_speculation_delay is not None:
4✔
443
        remote_cache_speculation_delay_millis = request.remote_cache_speculation_delay
×
444
    elif request.use_nailgun:
4✔
445
        remote_cache_speculation_delay_millis = jvm.nailgun_remote_cache_speculation_delay
4✔
446

447
    return Process(
4✔
448
        [*jdk.args(bash, request.classpath_entries), *jvm_options, *request.argv],
449
        input_digest=request.input_digest,
450
        immutable_input_digests=immutable_input_digests,
451
        use_nailgun=use_nailgun,
452
        description=request.description,
453
        level=request.level,
454
        output_directories=request.output_directories,
455
        env=env,
456
        timeout_seconds=request.timeout_seconds,
457
        append_only_caches=jdk.append_only_caches,
458
        output_files=request.output_files,
459
        cache_scope=request.cache_scope or ProcessCacheScope.SUCCESSFUL,
460
        remote_cache_speculation_delay_millis=remote_cache_speculation_delay_millis,
461
    )
462

463

464
def rules():
4✔
465
    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

© 2026 Coveralls, Inc