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

pantsbuild / pants / 18252174847

05 Oct 2025 01:36AM UTC coverage: 43.382% (-36.9%) from 80.261%
18252174847

push

github

web-flow
run tests on mac arm (#22717)

Just doing the minimal to pull forward the x86_64 pattern.

ref #20993

25776 of 59416 relevant lines covered (43.38%)

1.3 hits per line

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

65.13
/src/python/pants/backend/nfpm/fields/contents.py
1
# Copyright 2023 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
3✔
5

6
import os
3✔
7
import stat
3✔
8
from abc import ABCMeta
3✔
9
from collections.abc import Iterable
3✔
10
from enum import Enum
3✔
11
from typing import Any, ClassVar, cast
3✔
12

13
from pants.backend.nfpm.fields.all import NfpmDependencies
3✔
14
from pants.backend.nfpm.subsystem import MTIME_DEFAULT
3✔
15
from pants.core.target_types import RelocatedFiles
3✔
16
from pants.engine.addresses import Address
3✔
17
from pants.engine.target import (
3✔
18
    AsyncFieldMixin,
19
    ImmutableValue,
20
    IntField,
21
    InvalidFieldException,
22
    OptionalSingleSourceField,
23
    OverridesField,
24
    StringField,
25
    StringSequenceField,
26
    TupleSequenceField,
27
    ValidNumbers,
28
)
29
from pants.util.frozendict import FrozenDict
3✔
30
from pants.util.strutil import help_text
3✔
31

32
# -----------------------------------------------------------------------------------------------
33
# file_info fields
34
# -----------------------------------------------------------------------------------------------
35

36

37
class NfpmContentFileOwnerField(StringField):
3✔
38
    nfpm_alias = "contents.[].file_info.owner"
3✔
39
    alias: ClassVar[str] = "file_owner"
3✔
40
    default = "root"  # Make the nFPM default visible in help.
3✔
41
    help = help_text(
3✔
42
        """
43
        Username that should own this packaged file or directory.
44

45
        This is like the OWNER arg in chown: https://www.mankier.com/1/chown
46
        """
47
    )
48

49

50
class NfpmContentFileGroupField(StringField):
3✔
51
    nfpm_alias = "contents.[].file_info.group"
3✔
52
    alias: ClassVar[str] = "file_group"
3✔
53
    default = "root"  # Make the nFPM default visible in help.
3✔
54
    help = help_text(
3✔
55
        """
56
        Name of the group that should own this packaged file or directory.
57

58
        This is like the GROUP arg in chown: https://www.mankier.com/1/chown
59
        """
60
    )
61

62

63
# inlined private var from https://github.com/python/cpython/blob/3.9/Lib/stat.py#L128-L154
64
_FILEMODE_TABLE = (
3✔
65
    (),  # exclude irrelevant filetype bits that are in stat._filemode_table
66
    ((stat.S_IRUSR, "r"),),
67
    ((stat.S_IWUSR, "w"),),
68
    ((stat.S_IXUSR | stat.S_ISUID, "s"), (stat.S_ISUID, "S"), (stat.S_IXUSR, "x")),
69
    ((stat.S_IRGRP, "r"),),
70
    ((stat.S_IWGRP, "w"),),
71
    ((stat.S_IXGRP | stat.S_ISGID, "s"), (stat.S_ISGID, "S"), (stat.S_IXGRP, "x")),
72
    ((stat.S_IROTH, "r"),),
73
    ((stat.S_IWOTH, "w"),),
74
    ((stat.S_IXOTH | stat.S_ISVTX, "t"), (stat.S_ISVTX, "T"), (stat.S_IXOTH, "x")),
75
)
76
_FILEMODE_CHARS = frozenset("-rwxsStT")
3✔
77

78

79
def _parse_filemode(filemode: str) -> int:
3✔
80
    """Parse string filemode into octal representation.
81

82
    This is the opposite of stat.filemode, except that it does not support the filetype bits.
83
    """
84
    if len(filemode) != 9:
×
85
        raise ValueError(f"Symbolic file mode must be exactly 9 characters, not {len(filemode)}.")
×
86
    if not _FILEMODE_CHARS.issuperset(filemode):
×
87
        raise ValueError(
×
88
            "Cannot parse symbolic file mode with unknown symbols: "
89
            + "".join(set(filemode).difference(_FILEMODE_CHARS))
90
        )
91

92
    mode = 0
×
93
    # enumerate starting with index 1 to avoid irrelevant filetype bits.
94
    for i, symbol in enumerate(filemode, 1):
×
95
        if symbol == "-":
×
96
            continue
×
97
        for bit, char in _FILEMODE_TABLE[i]:
×
98
            if symbol == char:
×
99
                mode = mode | bit
×
100
                break
×
101
        else:
102
            # else none of the chars matched symbol (loop didn't hit break)
103
            raise ValueError(
×
104
                f"Symbol at position {i} is unknown: '{symbol}'. Valid symbols at "
105
                f"position {i}: {''.join(char for _, char in _FILEMODE_TABLE[i])}"
106
            )
107
    return mode
×
108

109

110
class NfpmContentFileModeField(IntField):
3✔
111
    nfpm_alias = "contents.[].file_info.mode"
3✔
112
    alias: ClassVar[str] = "file_mode"
3✔
113
    help = help_text(
3✔
114
        """
115
        A file mode as a numeric octal, an string octal, or a symbolic representation.
116

117
        NB: In most cases, you should set this field and not rely on the default value.
118
        Pants only tracks the executable bit for workspace files. So, this field defaults
119
        to 0o755 for executable files and 0o644 for files that are not executable.
120

121
        You may specify the file mode as: an octal, an octal string, or a symbolic string.
122
        If you specify a numeric octal (not as a string), make sure to include python's
123
        octal prefix: `0o` like in `0o644`. If you specify the octal as a string,
124
        the `Oo` prefix is optional (like `644`). If you specify a symbolic file mode string,
125
        you must provide 9 characters with "-" in place of any absent permissions
126
        (like `'rw-r--r--'`).
127

128
        For example to specify world readable/executable and user writable, these
129
        are equivalent:
130

131
          - `0o755`
132
          - `'755'`
133
          - `'rwxr-xr-x'`
134

135
        Another example for a file with read/write permissions for only the user:
136

137
          - `0o600`
138
          - `'600'`
139
          - `'rw-------'`
140

141
        Another example for a file with the group sticky bit set:
142

143
          - `0o2660`
144
          - `'2660'`
145
          - `'rw-rwS---'`
146

147
        WARNING: If you forget to include the `0o` prefix on a numeric octal, then
148
        it will be interpreted as an integer which is probably not what you want.
149
        For example, `755` (no quotes) will be processed as `0o1363` (symbolically
150
        that would be '-wxrw--wt') which is probably not what you intended. Pants
151
        cannot detect errors like this, so be careful to either use a string or
152
        include the `0o` octal prefix.
153
        """
154
    )
155

156
    # The octal should be between 0o0000 and 0o7777 (inclusive)
157
    valid_numbers = ValidNumbers.positive_and_zero
3✔
158

159
    @classmethod
3✔
160
    def compute_value(cls, raw_value: int | str | None, address: Address) -> int | None:
3✔
161
        if isinstance(raw_value, str):
×
162
            try:
×
163
                octal_value = int(raw_value, 8)
×
164
            except ValueError:
×
165
                try:
×
166
                    octal_value = _parse_filemode(raw_value)
×
167
                except ValueError as e:
×
168
                    raise InvalidFieldException(
×
169
                        f"The '{cls.alias}' field in target {address} must be "
170
                        "an octal (like 0o755 or 0o600), "
171
                        "an octal as a string (like '755' or '600'), "
172
                        "or a symbolic file mode (like 'rwxr-xr-x' or 'rw-------'). "
173
                        f"It is set to {repr(raw_value)}."
174
                    ) from e
175
            value = super().compute_value(octal_value, address)
×
176
        else:
177
            value = super().compute_value(raw_value, address)
×
178
        if value is not None and value > 0o7777:
×
179
            raise InvalidFieldException(
×
180
                f"The '{cls.alias} field in target {address} must be less than or equal to "
181
                f"0o7777, but was set to {repr(raw_value)} (ie: `{value:#o}`)."
182
            )
183
        return value
×
184

185

186
class NfpmContentFileMtimeField(StringField):
3✔
187
    nfpm_alias = "contents.[].file_info.mtime"
3✔
188
    alias: ClassVar[str] = "file_mtime"
3✔
189
    default = MTIME_DEFAULT
3✔
190
    help = help_text(
3✔
191
        f"""
192
        The file modification time as an RFC 3339 formatted string.
193

194
        For example: 2008-01-02T15:04:05Z
195

196
        The format is defined in RFC 3339: https://rfc-editor.org/rfc/rfc3339.html
197

198
        Though nFPM supports pulling mtime from the src file or directory in most
199
        cases, the pants nfpm backend does not support this. Reading the mtime from
200
        the filesystem is problematic because Pants does not track the mtime of files
201
        and does not propagate any file mtime into the sandboxes. Reasons for this
202
        include: git does not track mtime, timestamps like mtime cause many issues
203
        for reproducible packaging builds, and reproducible builds are required
204
        for pants to provide its fine-grained caches.
205

206
        The default value is {repr(MTIME_DEFAULT)}. You may also override
207
        the default value by setting `[nfpm].default_mtime` in `pants.toml`,
208
        or by setting the `SOURCE_DATE_EPOCH` environment variable.
209

210
        See also: https://reproducible-builds.org/docs/timestamps/
211
        """
212
    )
213

214
    def normalized_value(self, default_mtime: str | None):
3✔
215
        if self.value == self.default and default_mtime and self.value != default_mtime:
×
216
            return default_mtime
×
217
        return self.value
×
218

219

220
# -----------------------------------------------------------------------------------------------
221
# Internal generic parent class fields
222
# -----------------------------------------------------------------------------------------------
223

224

225
class _SrcDstSequenceField(TupleSequenceField):
3✔
226
    nfpm_alias = ""
3✔
227

228
    expected_element_type = str
3✔
229
    expected_element_count = 2
3✔
230
    expected_type_description = "an iterable of 2-string-tuples (tuple[tuple[str, str], ...])"
3✔
231
    expected_element_type_description = "2-string-tuples (tuple[str, str])"
3✔
232
    value: tuple[tuple[str, str], ...]
3✔
233

234
    # Subclasses must define these
235
    _dst_alias: ClassVar[str]
3✔
236

237
    @classmethod
3✔
238
    def compute_value(
3✔
239
        cls, raw_value: Iterable[Iterable[str]] | None, address: Address
240
    ) -> tuple[tuple[str, str], ...]:
241
        src_dst_map = super().compute_value(raw_value, address)
×
242
        if not src_dst_map:
×
243
            return ()
×
244

245
        # nFPM normalizes paths to be absolute, so "" is effectively the same as "/".
246
        # But, this does not validate the paths, leaving it up to nFPM to do any validation
247
        # that a specific packager might require.
248

249
        dst_seen = set()
×
250
        dst_dupes = set()
×
251
        for src, dst in src_dst_map:
×
252
            if dst in dst_seen:
×
253
                dst_dupes.add(dst)
×
254
            else:
255
                dst_seen.add(dst)
×
256
        if dst_dupes:
×
257
            raise InvalidFieldException(
×
258
                help_text(
259
                    f"""
260
                    '{cls._dst_alias}' must be unique in '{cls.alias}', but
261
                    found duplicate entries for: {repr(dst_dupes)}
262
                    """
263
                )
264
            )
265

266
        return cast("tuple[tuple[str, str], ...]", src_dst_map)
×
267

268

269
class _NfpmContentOverridesField(OverridesField):
3✔
270
    nfpm_alias = ""
3✔
271
    _disallow_overrides_for_field_aliases: tuple[str, ...] = ()
3✔
272

273
    @classmethod
3✔
274
    def compute_value(
3✔
275
        cls,
276
        raw_value: dict[str | tuple[str, ...], dict[str, Any]] | None,
277
        address: Address,
278
    ) -> FrozenDict[tuple[str, ...], FrozenDict[str, ImmutableValue]] | None:
279
        value = super().compute_value(raw_value, address)
×
280
        if not value:
×
281
            return None
×
282
        for dst, overrides in value.items():
×
283
            for field_alias in cls._disallow_overrides_for_field_aliases:
×
284
                if field_alias in overrides:
×
285
                    raise InvalidFieldException(
×
286
                        help_text(
287
                            f"""
288
                            '{cls.alias}' does not support overriding '{field_alias}'.
289
                            Please remove the '{field_alias}' override for: {dst}
290
                            """
291
                        )
292
                    )
293
        return value
×
294

295

296
# -----------------------------------------------------------------------------------------------
297
# File-specific fields
298
# -----------------------------------------------------------------------------------------------
299

300

301
class NfpmContentFileSourceField(OptionalSingleSourceField):
3✔
302
    nfpm_alias = ""
3✔
303
    none_is_valid_value = True
3✔
304
    help = help_text(
3✔
305
        lambda: f"""
306
        A file that should be copied into an nFPM package (optional).
307

308
        Either specify a file with '{NfpmContentFileSourceField.alias}', or use
309
        '{NfpmDependencies.alias}' to add a dependency on the target that owns
310
        the file.
311

312
        If both '{NfpmContentSrcField.alias}' and '{NfpmContentFileSourceField.alias}'
313
        are populated, then the file in '{NfpmContentFileSourceField.alias}' will be
314
        placed in the sandbox at the '{NfpmContentSrcField.alias}' path (similar to
315
        how the '{RelocatedFiles.alias}' target works).
316
        """
317
    )
318

319
    @classmethod
3✔
320
    def compute_value(cls, raw_value: str | None, address: Address) -> str | None:
3✔
321
        value_or_default = super().compute_value(raw_value, address)
×
322
        # This field should either have a path to a file or it should be None.
323
        # If it is a path to a file, we rely on standard glob_match_error_behavior
324
        # to inform the user of any issues finding the file.
325
        if value_or_default == "":
×
326
            # avoid ambiguity so we can use "is None" checks with this field.
327
            raise InvalidFieldException(
×
328
                help_text(
329
                    f"""\
330
                    The '{cls.alias}' field in target {address} should not be an empty string.
331
                    Did you mean to set it to None?
332
                    """
333
                )
334
            )
335
        return value_or_default
×
336

337

338
class NfpmContentSrcField(AsyncFieldMixin, StringField):
3✔
339
    nfpm_alias = "contents.[].src"
3✔
340
    alias: ClassVar[str] = "src"
3✔
341
    help = help_text(
3✔
342
        lambda: f"""
343
        A file path that should be included in the package.
344

345
        When the package gets installed, the file from '{NfpmContentSrcField.alias}'
346
        will be installed using the absolute path in '{NfpmContentDstField.alias}'.
347

348
        This path should be relative to the sandbox. The path should point to a
349
        generated file or a real file sourced from the workspace.
350

351
        Either '{NfpmContentSrcField.alias}' or '{NfpmContentFileSourceField.alias}'
352
        is required. If the '{NfpmContentFileSourceField.alias}' field is provided,
353
        then the '{NfpmContentSrcField.alias}' defaults to the file referenced in the
354
        '{NfpmContentFileSourceField.alias}' field.
355

356
        If both '{NfpmContentSrcField.alias}' and '{NfpmContentFileSourceField.alias}'
357
        are populated, then the file in '{NfpmContentFileSourceField.alias}' will be
358
        placed in the sandbox at the '{NfpmContentSrcField.alias}' path (similar to
359
        how the '{RelocatedFiles.alias}' target works).
360
        """
361
    )
362

363
    @property
3✔
364
    def file_path(self) -> str | None:
3✔
365
        """The path to the file, relative to the build root.
366

367
        The return type is optional because it's possible to have 0-1 files.
368
        """
369
        if self.value is None:
×
370
            return None
×
371
        return os.path.join(self.address.spec_path, self.value)
×
372

373

374
class NfpmContentDstBaseField(StringField, metaclass=ABCMeta):
3✔
375
    nfpm_alias = "contents.[].dst"
3✔
376
    alias: ClassVar[str] = "dst"
3✔
377

378

379
class NfpmContentDstField(NfpmContentDstBaseField):
3✔
380
    required = True
3✔
381
    value: str
3✔
382
    help = help_text(
3✔
383
        lambda: f"""
384
        The absolute install path for a packaged file.
385

386
        When the package gets installed, the file from '{NfpmContentSrcField.alias}'
387
        will be installed using the absolute path in '{NfpmContentDstField.alias}'.
388

389
        This path is an absolute path on the file system where the package
390
        will be installed.
391
        """
392
    )
393

394

395
class NfpmContentFileType(Enum):
3✔
396
    # This is a subset of the nFPM content types that apply to individual files.
397
    # Do not include these:
398
    # - dir (not a file: handled by nfpm_content_dir target)
399
    # - tree (not a file: pants needs more explicit config to build sandbox)
400
    # - ghost (handled by ghost_contents field on nfpm_rpm_package target)
401
    # - symlink (handled by a nfpm_content_symlink target)
402
    file = ""
3✔
403
    config = "config"
3✔
404
    # config|noreplace is only used by rpm.
405
    # For other packagers (deb, apk, archlinux) this is the same as "config".
406
    config_noreplace = "config|noreplace"
3✔
407
    # These are undocumented, but present in nFPM code for rpm.
408
    # If used, this changes the type used in the rpm header.
409
    # For other packagers (deb, apk, archlinux) this is the same as "file".
410
    doc = "doc"
3✔
411
    license = "license"  # nFPM also supports "licence"
3✔
412
    readme = "readme"
3✔
413

414

415
class NfpmContentTypeField(StringField):
3✔
416
    nfpm_alias = "contents.[].type"
3✔
417
    alias: ClassVar[str] = "content_type"
3✔
418
    valid_choices = NfpmContentFileType
3✔
419
    default = NfpmContentFileType.file.value
3✔
420
    value: str  # will not be None due to enum + default
3✔
421
    help = help_text(
3✔
422
        lambda: f"""
423
        The nFPM content type for the packaged file.
424

425
        The content type can be either
426
          - {repr(NfpmContentFileType.file.value)}: a normal file (the default), or
427
          - {repr(NfpmContentFileType.config.value)}: a config file.
428

429
        For RPM packaged files, the content type can also be one of:
430
          - {repr(NfpmContentFileType.config_noreplace.value)},
431
          - {repr(NfpmContentFileType.doc.value)},
432
          - {repr(NfpmContentFileType.license.value)}, and
433
          - {repr(NfpmContentFileType.readme.value)}.
434

435
        The {repr(NfpmContentFileType.config_noreplace.value)} type is used for RPM's
436
        `%config(noreplace)` option. For packagers other than RPM, using
437
        {repr(NfpmContentFileType.config_noreplace.value)} is the same as
438
        {repr(NfpmContentFileType.config.value)} and the remaining RPM-specific
439
        types are the same as {repr(NfpmContentFileType.file.value)}, a normal file.
440

441
        This field only supports file-specific nFPM content types.
442
        Please use these targets for non-file content:
443
          - For 'dir' content, use targets `nfpm_content_dir` and `nfpm_content_dirs`.
444
          - For 'symlink' content, use targets `nfpm_content_symlink` and `nfpm_content_symlinks`.
445
          - For RPM 'ghost' content, use field 'ghost_contents' on target `nfpm_rpm_package`.
446

447
        The nFPM 'tree' content type is not supported. Before passing the list of
448
        package contents to nFPM, pants expands target generators and any globs,
449
        so the 'tree' content type does not make sense.
450
        """
451
    )
452

453

454
class NfpmContentFilesField(_SrcDstSequenceField):
3✔
455
    alias: ClassVar[str] = "files"
3✔
456
    required = True
3✔
457
    help = help_text(
3✔
458
        lambda: f"""
459
        A list of 2-tuples ('{NfpmContentSrcField.alias}', '{NfpmContentDstField.alias}').
460

461
        The second part, `{NfpmContentDstField.alias}', must be unique across all entries.
462
        """
463
    )
464
    dst_alias = NfpmContentDstField.alias
3✔
465

466

467
class NfpmContentFilesOverridesField(_NfpmContentOverridesField):
3✔
468
    help = help_text(
3✔
469
        f"""
470
        Override the field values for generated `nfpm_content_file` targets.
471

472
        This expects a dictionary of '{NfpmContentDstField.alias}' files to a dictionary for the overrides.
473
        """
474
    )
475
    _disallow_overrides_for_field_aliases = (
3✔
476
        NfpmContentFileSourceField.alias,
477
        NfpmContentSrcField.alias,
478
        NfpmContentDstField.alias,
479
    )
480

481

482
# -----------------------------------------------------------------------------------------------
483
# Symlink-specific fields
484
# -----------------------------------------------------------------------------------------------
485

486

487
class NfpmContentSymlinkSrcField(NfpmContentSrcField):
3✔
488
    required = True
3✔
489
    value: str
3✔
490
    help = help_text(
3✔
491
        lambda: f"""
492
        The symlink target path (on package install).
493

494
        When the package gets installed, a symlink will be installed at the
495
        '{NfpmContentSymlinkDstField.alias}' path. The symlink will point to the
496
        '{NfpmContentSymlinkSrcField.alias}' path (the symlink target).
497

498
        This path is a path on the file system where the package will be installed.
499
        If this path is absolute, it is the absolute path to the symlink's target path.
500
        If this path is relative, it is relative to the '{NfpmContentSymlinkDstField.alias}'
501
        path, which is where the symlink will be created.
502
        """
503
    )
504

505

506
class NfpmContentSymlinkDstField(NfpmContentDstBaseField):
3✔
507
    required = True
3✔
508
    value: str
3✔
509
    help = help_text(
3✔
510
        lambda: f"""
511
        The symlink path (on package install).
512

513
        When the package gets installed, a symlink will be installed at the
514
        '{NfpmContentSymlinkDstField.alias}' path. The symlink will point to the
515
        '{NfpmContentSymlinkSrcField.alias}' path (the symlink target).
516

517
        This path is an absolute path on the file system where the package
518
        will be installed.
519
        """
520
    )
521

522

523
class NfpmContentSymlinksField(_SrcDstSequenceField):
3✔
524
    alias: ClassVar[str] = "symlinks"
3✔
525
    required = True
3✔
526
    help = help_text(
3✔
527
        lambda: f"""
528
        A list of 2-tuples ('{NfpmContentSymlinkSrcField.alias}', '{NfpmContentSymlinkDstField.alias}').
529

530
        The second part, `{NfpmContentSymlinkDstField.alias}', must be unique across all entries.
531
        """
532
    )
533
    _dst_alias = NfpmContentSymlinkDstField.alias
3✔
534

535

536
class NfpmContentSymlinksOverridesField(_NfpmContentOverridesField):
3✔
537
    help = help_text(
3✔
538
        f"""
539
        Override the field values for generated `nfpm_content_symlink` targets.
540

541
        This expects a dictionary of '{NfpmContentSymlinkDstField.alias}' files
542
        to a dictionary for the overrides.
543
        """
544
    )
545
    _disallow_overrides_for_field_aliases = (
3✔
546
        NfpmContentSymlinkSrcField.alias,
547
        NfpmContentSymlinkDstField.alias,
548
    )
549

550

551
# -----------------------------------------------------------------------------------------------
552
# Dir-specific fields
553
# -----------------------------------------------------------------------------------------------
554

555

556
class NfpmContentDirDstField(NfpmContentDstBaseField):
3✔
557
    required = True
3✔
558
    value: str
3✔
559
    help = help_text(
3✔
560
        lambda: f"""
561
        The absolute install path for a directory.
562

563
        When the package gets installed, a directory will be created at the
564
        '{NfpmContentDirDstField.alias}' path.
565

566
        This path is an absolute path on the file system where the package
567
        will be installed.
568
        """
569
    )
570

571

572
class NfpmContentDirsField(StringSequenceField):
3✔
573
    nfpm_alias = ""
3✔
574
    alias: ClassVar[str] = "dirs"
3✔
575
    required = True
3✔
576
    value: tuple[str, ...]
3✔
577
    help = help_text(
3✔
578
        lambda: f"""
579
        A list of install path for '{NfpmContentDirDstField.alias}' directories.
580

581
        When the package gets installed, each directory will be created.
582

583
        Each path is an absolute path on the file system where the package
584
        will be installed.
585
        """
586
    )
587

588
    @classmethod
3✔
589
    def compute_value(cls, raw_value: Iterable[str] | None, address: Address) -> tuple[str, ...]:
3✔
590
        dst_dirs = super().compute_value(raw_value, address)
×
591
        assert dst_dirs is not None  # it is required
×
592
        # nFPM normalizes paths to be absolute, so "" is effectively the same as "/".
593
        # But this does not validate the paths, leaving it up to nFPM to do any validation
594
        # that a specific packager might require.
595

596
        dst_seen = set()
×
597
        dst_dupes = set()
×
598
        for dst in dst_dirs:
×
599
            if dst in dst_seen:
×
600
                dst_dupes.add(dst)
×
601
            else:
602
                dst_seen.add(dst)
×
603
        if dst_dupes:
×
604
            raise InvalidFieldException(
×
605
                help_text(
606
                    lambda: f"""
607
                    '{NfpmContentDirDstField.alias}' must be unique in '{cls.alias}', but
608
                    found duplicate entries for: {repr(dst_dupes)}
609
                    """
610
                )
611
            )
612

613
        return dst_dirs
×
614

615

616
class NfpmContentDirsOverridesField(_NfpmContentOverridesField):
3✔
617
    help = help_text(
3✔
618
        f"""
619
        Override the field values for generated `nfpm_content_dir` targets.
620

621
        This expects a dictionary of '{NfpmContentDirDstField.alias}' files
622
        to a dictionary for the overrides.
623
        """
624
    )
625
    _disallow_overrides_for_field_aliases = (NfpmContentDirDstField.alias,)
3✔
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