• 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

39.73
/src/python/pants/backend/docker/goals/package_image.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
import json
4✔
6
import logging
4✔
7
import os
4✔
8
import re
4✔
9
from collections.abc import Iterator
4✔
10
from dataclasses import asdict, dataclass
4✔
11
from functools import partial
4✔
12
from itertools import chain
4✔
13
from typing import Literal, cast
4✔
14

15
# Re-exporting BuiltDockerImage here, as it has its natural home here, but has moved out to resolve
16
# a dependency cycle from docker_build_context.
17
from pants.backend.docker.package_types import BuiltDockerImage as BuiltDockerImage
4✔
18
from pants.backend.docker.registries import DockerRegistries, DockerRegistryOptions
4✔
19
from pants.backend.docker.subsystems.docker_options import DockerOptions
4✔
20
from pants.backend.docker.target_types import (
4✔
21
    DockerBuildKitOptionField,
22
    DockerBuildOptionFieldListOfMultiValueDictMixin,
23
    DockerBuildOptionFieldMixin,
24
    DockerBuildOptionFieldMultiValueDictMixin,
25
    DockerBuildOptionFieldMultiValueMixin,
26
    DockerBuildOptionFieldValueMixin,
27
    DockerBuildOptionFlagFieldMixin,
28
    DockerImageContextRootField,
29
    DockerImageRegistriesField,
30
    DockerImageRepositoryField,
31
    DockerImageSourceField,
32
    DockerImageTagsField,
33
    DockerImageTagsRequest,
34
    DockerImageTargetStageField,
35
    get_docker_image_tags,
36
)
37
from pants.backend.docker.util_rules.docker_binary import DockerBinary
4✔
38
from pants.backend.docker.util_rules.docker_build_context import (
4✔
39
    DockerBuildContext,
40
    DockerBuildContextRequest,
41
    create_docker_build_context,
42
)
43
from pants.backend.docker.utils import format_rename_suggestion
4✔
44
from pants.core.goals.package import BuiltPackage, OutputPathField, PackageFieldSet
4✔
45
from pants.engine.fs import CreateDigest, FileContent
4✔
46
from pants.engine.internals.graph import resolve_target
4✔
47
from pants.engine.intrinsics import create_digest, execute_process
4✔
48
from pants.engine.process import ProcessExecutionFailure
4✔
49
from pants.engine.rules import collect_rules, concurrently, implicitly, rule
4✔
50
from pants.engine.target import InvalidFieldException, Target, WrappedTargetRequest
4✔
51
from pants.engine.unions import UnionMembership, UnionRule
4✔
52
from pants.option.global_options import GlobalOptions, KeepSandboxes
4✔
53
from pants.util.strutil import bullet_list, softwrap
4✔
54
from pants.util.value_interpolation import InterpolationContext, InterpolationError
4✔
55

56
logger = logging.getLogger(__name__)
4✔
57

58

59
class DockerImageTagValueError(InterpolationError):
4✔
60
    pass
4✔
61

62

63
class DockerRepositoryNameError(InterpolationError):
4✔
64
    pass
4✔
65

66

67
class DockerBuildTargetStageError(ValueError):
4✔
68
    pass
4✔
69

70

71
class DockerImageOptionValueError(InterpolationError):
4✔
72
    pass
4✔
73

74

75
@dataclass(frozen=True)
4✔
76
class DockerPackageFieldSet(PackageFieldSet):
4✔
77
    required_fields = (DockerImageSourceField,)
4✔
78

79
    context_root: DockerImageContextRootField
4✔
80
    registries: DockerImageRegistriesField
4✔
81
    repository: DockerImageRepositoryField
4✔
82
    source: DockerImageSourceField
4✔
83
    tags: DockerImageTagsField
4✔
84
    target_stage: DockerImageTargetStageField
4✔
85
    output_path: OutputPathField
4✔
86

87
    def format_tag(self, tag: str, interpolation_context: InterpolationContext) -> str:
4✔
UNCOV
88
        source = InterpolationContext.TextSource(
×
89
            address=self.address, target_alias="docker_image", field_alias=self.tags.alias
90
        )
UNCOV
91
        return interpolation_context.format(tag, source=source, error_cls=DockerImageTagValueError)
×
92

93
    def format_repository(
4✔
94
        self,
95
        default_repository: str,
96
        interpolation_context: InterpolationContext,
97
        registry: DockerRegistryOptions | None = None,
98
    ) -> str:
UNCOV
99
        repository_context = InterpolationContext.from_dict(
×
100
            {
101
                "name": self.address.target_name,
102
                "directory": os.path.basename(self.address.spec_path),
103
                "full_directory": self.address.spec_path,
104
                "parent_directory": os.path.basename(os.path.dirname(self.address.spec_path)),
105
                "default_repository": default_repository,
106
                "target_repository": self.repository.value or default_repository,
107
                **interpolation_context,
108
            }
109
        )
UNCOV
110
        if registry and registry.repository:
×
UNCOV
111
            repository_text = registry.repository
×
UNCOV
112
            source = InterpolationContext.TextSource(
×
113
                options_scope=f"[docker.registries.{registry.alias or registry.address}].repository"
114
            )
UNCOV
115
        elif self.repository.value:
×
UNCOV
116
            repository_text = self.repository.value
×
UNCOV
117
            source = InterpolationContext.TextSource(
×
118
                address=self.address, target_alias="docker_image", field_alias=self.repository.alias
119
            )
120
        else:
UNCOV
121
            repository_text = default_repository
×
UNCOV
122
            source = InterpolationContext.TextSource(options_scope="[docker].default_repository")
×
UNCOV
123
        return repository_context.format(
×
124
            repository_text, source=source, error_cls=DockerRepositoryNameError
125
        ).lower()
126

127
    def format_image_ref_tags(
4✔
128
        self,
129
        repository: str,
130
        tags: tuple[str, ...],
131
        interpolation_context: InterpolationContext,
132
        uses_local_alias,
133
    ) -> Iterator[ImageRefTag]:
UNCOV
134
        for tag in tags:
×
UNCOV
135
            formatted = self.format_tag(tag, interpolation_context)
×
UNCOV
136
            yield ImageRefTag(
×
137
                template=tag,
138
                formatted=formatted,
139
                full_name=":".join(s for s in [repository, formatted] if s),
140
                uses_local_alias=uses_local_alias,
141
            )
142

143
    def image_refs(
4✔
144
        self,
145
        default_repository: str,
146
        registries: DockerRegistries,
147
        interpolation_context: InterpolationContext,
148
        additional_tags: tuple[str, ...] = (),
149
    ) -> Iterator[ImageRefRegistry]:
150
        """The per-registry image refs: each returned element is a collection of the tags applied to
151
        the image in a single registry.
152

153
        In the Docker world, the term `tag` is used both for what we here prefer to call the image
154
        `ref`, as well as for the image version, or tag, that is at the end of the image name
155
        separated with a colon. By introducing the image `ref` we can retain the use of `tag` for
156
        the version part of the image name.
157

158
        This function returns all image refs to apply to the Docker image, grouped by
159
        registry. Within each registry, the `tags` attribute contains a metadata about each tag in
160
        the context of that registry, and the `full_name` attribute of each `ImageRefTag` provides
161
        the image ref, of the following form:
162

163
            [<registry>/]<repository-name>[:<tag>]
164

165
        Where the `<repository-name>` may contain any number of separating slashes `/`, depending on
166
        the `default_repository` from configuration or the `repository` field on the target
167
        `docker_image`.
168

169
        This method will always return at least one `ImageRefRegistry`, and there will be at least
170
        one tag.
171
        """
UNCOV
172
        image_tags = (self.tags.value or ()) + additional_tags
×
UNCOV
173
        registries_options = tuple(registries.get(*(self.registries.value or [])))
×
UNCOV
174
        if not registries_options:
×
175
            # The image name is also valid as image ref without registry.
UNCOV
176
            repository = self.format_repository(default_repository, interpolation_context)
×
UNCOV
177
            yield ImageRefRegistry(
×
178
                registry=None,
179
                repository=repository,
180
                tags=tuple(
181
                    self.format_image_ref_tags(
182
                        repository, image_tags, interpolation_context, uses_local_alias=False
183
                    )
184
                ),
185
            )
UNCOV
186
            return
×
187

UNCOV
188
        for registry in registries_options:
×
UNCOV
189
            repository = self.format_repository(default_repository, interpolation_context, registry)
×
UNCOV
190
            address_repository = "/".join([registry.address, repository])
×
UNCOV
191
            if registry.use_local_alias and registry.alias:
×
UNCOV
192
                alias_repository = "/".join([registry.alias, repository])
×
193
            else:
UNCOV
194
                alias_repository = None
×
195

UNCOV
196
            yield ImageRefRegistry(
×
197
                registry=registry,
198
                repository=repository,
199
                tags=(
200
                    *self.format_image_ref_tags(
201
                        address_repository,
202
                        image_tags + registry.extra_image_tags,
203
                        interpolation_context,
204
                        uses_local_alias=False,
205
                    ),
206
                    *(
207
                        self.format_image_ref_tags(
208
                            alias_repository,
209
                            image_tags + registry.extra_image_tags,
210
                            interpolation_context,
211
                            uses_local_alias=True,
212
                        )
213
                        if alias_repository
214
                        else []
215
                    ),
216
                ),
217
            )
218

219
    def get_context_root(self, default_context_root: str) -> str:
4✔
220
        """Examines `default_context_root` and `self.context_root.value` and translates that to a
221
        context root for the Docker build operation.
222

223
        That is, in the configuration/field value, the context root is relative to build root when
224
        in the form `path/..` (implies semantics as `//path/..` for target addresses) or the BUILD
225
        file when `./path/..`.
226

227
        The returned path is always relative to the build root.
228
        """
UNCOV
229
        if self.context_root.value is not None:
×
UNCOV
230
            context_root = self.context_root.value
×
231
        else:
UNCOV
232
            context_root = cast(
×
233
                str, self.context_root.compute_value(default_context_root, self.address)
234
            )
UNCOV
235
        if context_root.startswith("./"):
×
UNCOV
236
            context_root = os.path.join(self.address.spec_path, context_root)
×
UNCOV
237
        return os.path.normpath(context_root)
×
238

239

240
@dataclass(frozen=True)
4✔
241
class ImageRefRegistry:
4✔
242
    registry: DockerRegistryOptions | None
4✔
243
    repository: str
4✔
244
    tags: tuple[ImageRefTag, ...]
4✔
245

246

247
@dataclass(frozen=True)
4✔
248
class ImageRefTag:
4✔
249
    template: str
4✔
250
    formatted: str
4✔
251
    full_name: str
4✔
252
    uses_local_alias: bool
4✔
253

254

255
@dataclass(frozen=True)
4✔
256
class DockerInfoV1:
4✔
257
    """The format of the `$target_name.docker-info.json` file."""
258

259
    version: Literal[1]
4✔
260
    image_id: str
4✔
261
    # It'd be good to include the digest here (e.g. to allow 'docker run
262
    # registry/repository@digest'), but that is only known after pushing to a V2 registry
263

264
    registries: list[DockerInfoV1Registry]
4✔
265

266
    @staticmethod
4✔
267
    def serialize(image_refs: tuple[ImageRefRegistry, ...], image_id: str) -> bytes:
4✔
268
        # make sure these are in a consistent order (the exact order doesn't matter
269
        # so much), no matter how they were configured
UNCOV
270
        sorted_refs = sorted(image_refs, key=lambda r: r.registry.address if r.registry else "")
×
271

UNCOV
272
        info = DockerInfoV1(
×
273
            version=1,
274
            image_id=image_id,
275
            registries=[
276
                DockerInfoV1Registry(
277
                    alias=r.registry.alias if r.registry and r.registry.alias else None,
278
                    address=r.registry.address if r.registry else None,
279
                    repository=r.repository,
280
                    tags=[
281
                        DockerInfoV1ImageTag(
282
                            template=t.template,
283
                            tag=t.formatted,
284
                            uses_local_alias=t.uses_local_alias,
285
                            name=t.full_name,
286
                        )
287
                        # consistent order, as above
288
                        for t in sorted(r.tags, key=lambda t: t.full_name)
289
                    ],
290
                )
291
                for r in sorted_refs
292
            ],
293
        )
294

UNCOV
295
        return json.dumps(asdict(info)).encode()
×
296

297

298
@dataclass(frozen=True)
4✔
299
class DockerInfoV1Registry:
4✔
300
    # set if registry was specified as `@something`
301
    alias: str | None
4✔
302
    address: str | None
4✔
303
    repository: str
4✔
304
    tags: list[DockerInfoV1ImageTag]
4✔
305

306

307
@dataclass(frozen=True)
4✔
308
class DockerInfoV1ImageTag:
4✔
309
    template: str
4✔
310
    tag: str
4✔
311
    uses_local_alias: bool
4✔
312
    # for convenience, include the concatenated registry/repository:tag name (using this tag)
313
    name: str
4✔
314

315

316
def get_build_options(
4✔
317
    context: DockerBuildContext,
318
    field_set: DockerPackageFieldSet,
319
    global_target_stage_option: str | None,
320
    global_build_hosts_options: dict | None,
321
    global_build_no_cache_option: bool | None,
322
    use_buildx_option: bool,
323
    target: Target,
324
) -> Iterator[str]:
325
    # Build options from target fields inheriting from DockerBuildOptionFieldMixin
UNCOV
326
    for field_type in target.field_types:
×
UNCOV
327
        if issubclass(field_type, DockerBuildKitOptionField):
×
UNCOV
328
            if use_buildx_option is not True:
×
UNCOV
329
                if target[field_type].value != target[field_type].default:
×
UNCOV
330
                    raise DockerImageOptionValueError(
×
331
                        f"The {target[field_type].alias} field on the = `{target.alias}` target in `{target.address}` was set to `{target[field_type].value}`"
332
                        f" and buildx is not enabled. Buildx must be enabled via the Docker subsystem options in order to use this field."
333
                    )
334
                else:
335
                    # Case where BuildKit option has a default value - still should not be generated
UNCOV
336
                    continue
×
337

UNCOV
338
        if issubclass(
×
339
            field_type,
340
            (
341
                DockerBuildOptionFieldMixin,
342
                DockerBuildOptionFieldMultiValueDictMixin,
343
                DockerBuildOptionFieldListOfMultiValueDictMixin,
344
                DockerBuildOptionFieldValueMixin,
345
                DockerBuildOptionFieldMultiValueMixin,
346
                DockerBuildOptionFlagFieldMixin,
347
            ),
348
        ):
UNCOV
349
            source = InterpolationContext.TextSource(
×
350
                address=target.address, target_alias=target.alias, field_alias=field_type.alias
351
            )
UNCOV
352
            format = partial(
×
353
                context.interpolation_context.format,
354
                source=source,
355
                error_cls=DockerImageOptionValueError,
356
            )
UNCOV
357
            yield from target[field_type].options(
×
358
                format, global_build_hosts_options=global_build_hosts_options
359
            )
360

361
    # Target stage
UNCOV
362
    target_stage = None
×
UNCOV
363
    if global_target_stage_option in context.stages:
×
UNCOV
364
        target_stage = global_target_stage_option
×
UNCOV
365
    elif field_set.target_stage.value:
×
UNCOV
366
        target_stage = field_set.target_stage.value
×
UNCOV
367
        if target_stage not in context.stages:
×
UNCOV
368
            raise DockerBuildTargetStageError(
×
369
                f"The {field_set.target_stage.alias!r} field in `{target.alias}` "
370
                f"{field_set.address} was set to {target_stage!r}"
371
                + (
372
                    f", but there is no such stage in `{context.dockerfile}`. "
373
                    f"Available stages: {', '.join(context.stages)}."
374
                    if context.stages
375
                    else f", but there are no named stages in `{context.dockerfile}`."
376
                )
377
            )
378

UNCOV
379
    if target_stage:
×
UNCOV
380
        yield from ("--target", target_stage)
×
381

UNCOV
382
    if global_build_no_cache_option:
×
UNCOV
383
        yield "--no-cache"
×
384

385

386
@rule
4✔
387
async def build_docker_image(
4✔
388
    field_set: DockerPackageFieldSet,
389
    options: DockerOptions,
390
    global_options: GlobalOptions,
391
    docker: DockerBinary,
392
    keep_sandboxes: KeepSandboxes,
393
    union_membership: UnionMembership,
394
) -> BuiltPackage:
395
    """Build a Docker image using `docker build`."""
UNCOV
396
    context, wrapped_target = await concurrently(
×
397
        create_docker_build_context(
398
            DockerBuildContextRequest(
399
                address=field_set.address,
400
                build_upstream_images=True,
401
            ),
402
            **implicitly(),
403
        ),
404
        resolve_target(
405
            WrappedTargetRequest(field_set.address, description_of_origin="<infallible>"),
406
            **implicitly(),
407
        ),
408
    )
409

UNCOV
410
    image_tags_requests = union_membership.get(DockerImageTagsRequest)
×
UNCOV
411
    additional_image_tags = await concurrently(
×
412
        get_docker_image_tags(
413
            **implicitly({image_tags_request_cls(wrapped_target.target): DockerImageTagsRequest})
414
        )
415
        for image_tags_request_cls in image_tags_requests
416
        if image_tags_request_cls.is_applicable(wrapped_target.target)
417
    )
418

UNCOV
419
    image_refs = tuple(
×
420
        field_set.image_refs(
421
            default_repository=options.default_repository,
422
            registries=options.registries(),
423
            interpolation_context=context.interpolation_context,
424
            additional_tags=tuple(chain.from_iterable(additional_image_tags)),
425
        )
426
    )
UNCOV
427
    tags = tuple(tag.full_name for registry in image_refs for tag in registry.tags)
×
UNCOV
428
    if not tags:
×
UNCOV
429
        raise InvalidFieldException(
×
430
            softwrap(
431
                f"""
432
                The `{DockerImageTagsField.alias}` field in target {field_set.address} must not be
433
                empty, unless there is a custom plugin providing additional tags using the
434
                `DockerImageTagsRequest` union type.
435
                """
436
            )
437
        )
438

439
    # Mix the upstream image ids into the env to ensure that Pants invalidates this
440
    # image-building process correctly when an upstream image changes, even though the
441
    # process itself does not consume this data.
UNCOV
442
    env = {
×
443
        **context.build_env.environment,
444
        "__UPSTREAM_IMAGE_IDS": ",".join(context.upstream_image_ids),
445
    }
UNCOV
446
    context_root = field_set.get_context_root(options.default_context_root)
×
UNCOV
447
    process = docker.build_image(
×
448
        build_args=context.build_args,
449
        digest=context.digest,
450
        dockerfile=context.dockerfile,
451
        context_root=context_root,
452
        env=env,
453
        tags=tags,
454
        use_buildx=options.use_buildx,
455
        extra_args=tuple(
456
            get_build_options(
457
                context=context,
458
                field_set=field_set,
459
                global_target_stage_option=options.build_target_stage,
460
                global_build_hosts_options=options.build_hosts,
461
                global_build_no_cache_option=options.build_no_cache,
462
                use_buildx_option=options.use_buildx,
463
                target=wrapped_target.target,
464
            )
465
        ),
466
    )
UNCOV
467
    result = await execute_process(process, **implicitly())
×
468

UNCOV
469
    if result.exit_code != 0:
×
UNCOV
470
        msg = f"Docker build failed for `docker_image` {field_set.address}."
×
UNCOV
471
        if options.suggest_renames:
×
UNCOV
472
            maybe_help_msg = format_docker_build_context_help_message(
×
473
                context_root=context_root,
474
                context=context,
475
                colors=global_options.colors,
476
            )
UNCOV
477
            if maybe_help_msg:
×
UNCOV
478
                msg += " " + maybe_help_msg
×
479

UNCOV
480
        logger.warning(msg)
×
481

UNCOV
482
        raise ProcessExecutionFailure(
×
483
            result.exit_code,
484
            result.stdout,
485
            result.stderr,
486
            process.description,
487
            keep_sandboxes=keep_sandboxes,
488
        )
489

UNCOV
490
    image_id = parse_image_id_from_docker_build_output(docker, result.stdout, result.stderr)
×
UNCOV
491
    docker_build_output_msg = "\n".join(
×
492
        (
493
            f"Docker build output for {tags[0]}:",
494
            "stdout:",
495
            result.stdout.decode(),
496
            "stderr:",
497
            result.stderr.decode(),
498
        )
499
    )
500

UNCOV
501
    if options.build_verbose:
×
502
        logger.info(docker_build_output_msg)
×
503
    else:
UNCOV
504
        logger.debug(docker_build_output_msg)
×
505

UNCOV
506
    metadata_filename = field_set.output_path.value_or_default(file_ending="docker-info.json")
×
UNCOV
507
    metadata = DockerInfoV1.serialize(image_refs, image_id=image_id)
×
UNCOV
508
    digest = await create_digest(CreateDigest([FileContent(metadata_filename, metadata)]))
×
509

UNCOV
510
    return BuiltPackage(
×
511
        digest,
512
        (BuiltDockerImage.create(image_id, tags, metadata_filename),),
513
    )
514

515

516
def parse_image_id_from_docker_build_output(docker: DockerBinary, *outputs: bytes) -> str:
4✔
517
    """Outputs are typically the stdout/stderr pair from the `docker build` process."""
518
    # NB: We use the extracted image id for invalidation. The short_id may theoretically
519
    #  not be unique enough, although in a non adversarial situation, this is highly unlikely
520
    #  to be an issue in practice.
UNCOV
521
    if docker.is_podman:
×
UNCOV
522
        for output in outputs:
×
UNCOV
523
            try:
×
UNCOV
524
                _, image_id, success, *__ = reversed(output.decode().split("\n"))
×
525
            except ValueError:
×
526
                continue
×
527

UNCOV
528
            if success.startswith("Successfully tagged"):
×
UNCOV
529
                return image_id
×
530

531
    else:
UNCOV
532
        image_id_regexp = re.compile(
×
533
            "|".join(
534
                (
535
                    # BuildKit output.
536
                    r"(writing image (?P<digest>sha256:\S+))",
537
                    # BuildKit with containerd-snapshotter output.
538
                    r"(exporting manifest list (?P<manifest_list>sha256:\S+))",
539
                    # BuildKit with containerd-snapshotter output and no attestation.
540
                    r"(exporting manifest (?P<manifest>sha256:\S+))",
541
                    # Docker output.
542
                    r"(Successfully built (?P<short_id>\S+))",
543
                ),
544
            )
545
        )
UNCOV
546
        for output in outputs:
×
UNCOV
547
            image_id_match = next(
×
548
                (
549
                    match
550
                    for match in (
551
                        re.search(image_id_regexp, line)
552
                        for line in reversed(output.decode().split("\n"))
553
                    )
554
                    if match
555
                ),
556
                None,
557
            )
UNCOV
558
            if image_id_match:
×
UNCOV
559
                image_id = (
×
560
                    image_id_match.group("digest")
561
                    or image_id_match.group("short_id")
562
                    or image_id_match.group("manifest_list")
563
                    or image_id_match.group("manifest")
564
                )
UNCOV
565
                return image_id
×
566

UNCOV
567
    return "<unknown>"
×
568

569

570
def format_docker_build_context_help_message(
4✔
571
    context_root: str, context: DockerBuildContext, colors: bool
572
) -> str | None:
UNCOV
573
    paths_outside_context_root: list[str] = []
×
574

UNCOV
575
    def _chroot_context_paths(paths: tuple[str, str]) -> tuple[str, str]:
×
576
        """Adjust the context paths in `copy_source_vs_context_source` for `context_root`."""
UNCOV
577
        instruction_path, context_path = paths
×
UNCOV
578
        if not context_path:
×
579
            return paths
×
UNCOV
580
        dst = os.path.relpath(context_path, context_root)
×
UNCOV
581
        if dst.startswith("../"):
×
UNCOV
582
            paths_outside_context_root.append(context_path)
×
UNCOV
583
            return ("", "")
×
UNCOV
584
        if instruction_path == dst:
×
UNCOV
585
            return ("", "")
×
UNCOV
586
        return instruction_path, dst
×
587

588
    # Adjust context paths based on `context_root`.
UNCOV
589
    copy_source_vs_context_source: tuple[tuple[str, str], ...] = tuple(
×
590
        filter(any, map(_chroot_context_paths, context.copy_source_vs_context_source))
591
    )
592

UNCOV
593
    if not (copy_source_vs_context_source or paths_outside_context_root):
×
594
        # No issues found.
595
        return None
×
596

UNCOV
597
    msg = ""
×
UNCOV
598
    has_unsourced_copy = any(src for src, _ in copy_source_vs_context_source)
×
UNCOV
599
    if has_unsourced_copy:
×
UNCOV
600
        msg += (
×
601
            f"The {context.dockerfile} has `COPY` instructions for source files that may not have "
602
            f"been found in the Docker build context.\n\n"
603
        )
604

UNCOV
605
        renames = sorted(
×
606
            format_rename_suggestion(src, dst, colors=colors)
607
            for src, dst in copy_source_vs_context_source
608
            if src and dst
609
        )
UNCOV
610
        if renames:
×
UNCOV
611
            msg += (
×
612
                f"However there are possible matches. Please review the following list of "
613
                f"suggested renames:\n\n{bullet_list(renames)}\n\n"
614
            )
615

UNCOV
616
        unknown = sorted(src for src, dst in copy_source_vs_context_source if src and not dst)
×
UNCOV
617
        if unknown:
×
618
            msg += (
×
619
                f"The following files were not found in the Docker build context:\n\n"
620
                f"{bullet_list(unknown)}\n\n"
621
            )
622

UNCOV
623
    unreferenced = sorted(dst for src, dst in copy_source_vs_context_source if dst and not src)
×
UNCOV
624
    if unreferenced:
×
UNCOV
625
        msg += (
×
626
            f"There are files in the Docker build context that were not referenced by "
627
            f"any `COPY` instruction (this is not an error):\n\n{bullet_list(unreferenced, 10)}\n\n"
628
        )
629

UNCOV
630
    if paths_outside_context_root:
×
UNCOV
631
        unreachable = sorted({os.path.dirname(pth) for pth in paths_outside_context_root})
×
UNCOV
632
        context_paths = tuple(dst for src, dst in context.copy_source_vs_context_source if dst)
×
UNCOV
633
        new_context_root = os.path.commonpath(context_paths)
×
UNCOV
634
        msg += (
×
635
            "There are unreachable files in these directories, excluded from the build context "
636
            f"due to `context_root` being {context_root!r}:\n\n{bullet_list(unreachable, 10)}\n\n"
637
            f"Suggested `context_root` setting is {new_context_root!r} in order to include all "
638
            "files in the build context, otherwise relocate the files to be part of the current "
639
            f"`context_root` {context_root!r}."
640
        )
641

UNCOV
642
    return msg
×
643

644

645
def rules():
4✔
646
    return [
3✔
647
        *collect_rules(),
648
        UnionRule(PackageFieldSet, DockerPackageFieldSet),
649
    ]
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