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

pantsbuild / pants / 21457998286

28 Jan 2026 10:32PM UTC coverage: 80.281% (+0.01%) from 80.269%
21457998286

Pull #23037

github

web-flow
Merge 43b38d939 into 0fdb40370
Pull Request #23037: Enable publish without package 2

275 of 328 new or added lines in 13 files covered. (83.84%)

46 existing lines in 9 files now uncovered.

78960 of 98355 relevant lines covered (80.28%)

3.36 hits per line

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

61.17
/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
12✔
5

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

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

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

39

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

44

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

49

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

54
    version: str | DefaultJdk
12✔
55

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

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

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

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

75
        if not fields:
×
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]
×
82
        if not all(f.value == field.value for f in fields):
×
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)
×
90

91

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

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

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

113
        return (
×
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
12✔
122
    def env(self) -> dict[str, str]:
12✔
123
        return self.coursier.env
×
124

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

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

133

134
@dataclass(frozen=True)
12✔
135
class InternalJdk(JdkEnvironment):
12✔
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
12✔
143
    def from_jdk_environment(cls, env: JdkEnvironment) -> InternalJdk:
12✔
144
        return cls(
×
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 \"(.+?)\"")
12✔
155

156

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

165

166
@rule
12✔
167
async def fetch_nailgun() -> Nailgun:
12✔
168
    nailgun = await coursier_fetch_one_coord(
×
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)
×
182

183

184
@rule
12✔
185
async def prepare_jdk_environment(
12✔
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
×
196

197
    version = request.version
×
198
    if version == DefaultJdk.SOURCE_DEFAULT:
×
199
        version = jvm.jdk
×
200

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

207
    if not coursier.jvm_index:
×
208
        coursier_options = ["java-home", "--quiet", "--quiet", coursier_jdk_option]
×
209
    else:
210
        jvm_index_option = f"--jvm-index={coursier.jvm_index}"
×
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.
UNCOV
221
    def prefixed(arg: str) -> str:
×
222
        quoted = shlex.quote(arg)
×
UNCOV
223
        if arg.startswith("__"):
×
UNCOV
224
            return f"${{PANTS_INTERNAL_ABSOLUTE_PREFIX}}{quoted}"
×
225
        else:
UNCOV
226
            return quoted
×
227

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

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

UNCOV
238
    java_version_result = await execute_process(
×
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:
×
256
        raise ValueError(
×
257
            f"Failed to locate Java for JDK `{version}`:\n"
258
            f"{java_version_result.stderr.decode('utf-8')}"
259
        )
260

UNCOV
261
    java_version = java_version_result.stderr.decode("utf-8").strip()
×
UNCOV
262
    jre_major_version = parse_jre_major_version(java_version)
×
UNCOV
263
    if not jre_major_version:
×
UNCOV
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`.
UNCOV
272
    version_comment = "\n".join(f"# {line}" for line in java_version.splitlines())
×
UNCOV
273
    ln_path = shlex.quote(ln.path)
×
UNCOV
274
    jdk_preparation_script = textwrap.dedent(  # noqa: PNT20
×
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
    )
UNCOV
284
    jdk_preparation_script_digest = await create_digest(
×
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

UNCOV
296
    return JdkEnvironment(
×
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
12✔
314
async def internal_jdk(jvm: JvmSubsystem) -> InternalJdk:
12✔
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

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

325

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

345
    def __init__(
12✔
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)
2✔
365
        object.__setattr__(self, "argv", tuple(argv))
2✔
366
        object.__setattr__(self, "classpath_entries", tuple(classpath_entries))
2✔
367
        object.__setattr__(self, "input_digest", input_digest)
2✔
368
        object.__setattr__(self, "description", description)
2✔
369
        object.__setattr__(self, "level", level)
2✔
370
        object.__setattr__(self, "extra_jvm_options", tuple(extra_jvm_options or ()))
2✔
371
        object.__setattr__(self, "extra_nailgun_keys", tuple(extra_nailgun_keys or ()))
2✔
372
        object.__setattr__(self, "output_files", tuple(output_files or ()))
2✔
373
        object.__setattr__(self, "output_directories", tuple(output_directories or ()))
2✔
374
        object.__setattr__(self, "timeout_seconds", timeout_seconds)
2✔
375
        object.__setattr__(self, "cache_scope", cache_scope)
2✔
376
        object.__setattr__(
2✔
377
            self, "extra_immutable_input_digests", FrozenDict(extra_immutable_input_digests or {})
378
        )
379
        object.__setattr__(self, "extra_env", FrozenDict(extra_env or {}))
2✔
380
        object.__setattr__(self, "use_nailgun", use_nailgun)
2✔
381
        object.__setattr__(self, "remote_cache_speculation_delay", remote_cache_speculation_delay)
2✔
382

383
        self.__post_init__()
2✔
384

385
    def __post_init__(self):
12✔
386
        if not self.use_nailgun and self.extra_nailgun_keys:
2✔
UNCOV
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"]
12✔
394

395

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

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

UNCOV
412
    def valid_jvm_opt(opt: str) -> str:
×
UNCOV
413
        if opt.startswith("-Xmx"):
×
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
×
427

UNCOV
428
    max_heap_size = fmt_memory_size(
×
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]
×
432
    jvm_options = [
×
433
        f"-Xmx{max_heap_size}",
434
        *[valid_jvm_opt(opt) for opt in jvm_user_options],
435
    ]
436

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

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

UNCOV
447
    return Process(
×
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():
12✔
465
    return collect_rules()
12✔
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