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

pantsbuild / pants / 18812500213

26 Oct 2025 03:42AM UTC coverage: 80.284% (+0.005%) from 80.279%
18812500213

Pull #22804

github

web-flow
Merge 2a56fdb46 into 4834308dc
Pull Request #22804: test_shell_command: use correct default cache scope for a test's environment

29 of 31 new or added lines in 2 files covered. (93.55%)

1314 existing lines in 64 files now uncovered.

77900 of 97030 relevant lines covered (80.28%)

3.35 hits per line

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

94.42
/src/python/pants/backend/python/util_rules/interpreter_constraints.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 itertools
12✔
7
import logging
12✔
8
import re
12✔
9
from collections import defaultdict
12✔
10
from collections.abc import Iterable, Iterator, Sequence
12✔
11
from typing import Protocol, TypeVar
12✔
12

13
from packaging.requirements import InvalidRequirement, Requirement
12✔
14

15
from pants.backend.python.subsystems.setup import PythonSetup
12✔
16
from pants.backend.python.target_types import InterpreterConstraintsField
12✔
17
from pants.build_graph.address import Address
12✔
18
from pants.engine.engine_aware import EngineAwareParameter
12✔
19
from pants.engine.target import Target
12✔
20
from pants.util.docutil import bin_name
12✔
21
from pants.util.frozendict import FrozenDict
12✔
22
from pants.util.memo import memoized
12✔
23
from pants.util.ordered_set import FrozenOrderedSet, OrderedSet
12✔
24
from pants.util.strutil import softwrap
12✔
25

26
logger = logging.getLogger(__name__)
12✔
27

28

29
# This protocol allows us to work with any arbitrary FieldSet. See
30
# https://mypy.readthedocs.io/en/stable/protocols.html.
31
class FieldSetWithInterpreterConstraints(Protocol):
12✔
32
    @property
33
    def address(self) -> Address: ...
34

35
    @property
36
    def interpreter_constraints(self) -> InterpreterConstraintsField: ...
37

38

39
_FS = TypeVar("_FS", bound=FieldSetWithInterpreterConstraints)
12✔
40

41

42
RawConstraints = tuple[str, ...]
12✔
43

44

45
# The current maxes are 2.7.18 and 3.6.15.  We go much higher, for safety.
46
_PATCH_VERSION_UPPER_BOUND = 30
12✔
47

48

49
@memoized
12✔
50
def interpreter_constraints_contains(
12✔
51
    a: RawConstraints, b: RawConstraints, interpreter_universe: tuple[str, ...]
52
) -> bool:
53
    """A memoized version of `InterpreterConstraints.contains`.
54

55
    This is a function in order to keep the memoization cache on the module rather than on an
56
    instance. It can't go on `PythonSetup`, since that would cause a cycle with this module.
57
    """
58
    return InterpreterConstraints(a).contains(InterpreterConstraints(b), interpreter_universe)
×
59

60

61
@memoized
12✔
62
def parse_constraint(constraint: str) -> Requirement:
12✔
63
    """Parse an interpreter constraint, e.g., CPython>=2.7,<3.
64

65
    We allow shorthand such as `>=3.7`, which gets expanded to `CPython>=3.7`. See Pex's
66
    interpreter.py's `parse_requirement()`.
67
    """
68
    try:
12✔
69
        parsed_requirement = Requirement(constraint)
12✔
70
    except InvalidRequirement as err:
7✔
71
        try:
7✔
72
            parsed_requirement = Requirement(f"CPython{constraint}")
7✔
73
        except InvalidRequirement:
1✔
74
            raise InvalidRequirement(
1✔
75
                f"Failed to parse Python interpreter constraint `{constraint}`: {err}"
76
            )
77

78
    return parsed_requirement
12✔
79

80

81
# Normally we would subclass `DeduplicatedCollection`, but we want a custom constructor.
82
class InterpreterConstraints(FrozenOrderedSet[Requirement], EngineAwareParameter):
12✔
83
    @classmethod
12✔
84
    def for_fixed_python_version(
12✔
85
        cls, python_version_str: str, interpreter_type: str = "CPython"
86
    ) -> InterpreterConstraints:
87
        return cls([f"{interpreter_type}=={python_version_str}"])
×
88

89
    def __init__(self, constraints: Iterable[str | Requirement] = ()) -> None:
12✔
90
        # #12578 `parse_constraint` will sort the requirement's component constraints into a stable form.
91
        # We need to sort the component constraints for each requirement _before_ sorting the entire list
92
        # for the ordering to be correct.
93
        parsed_constraints = (
12✔
94
            i if isinstance(i, Requirement) else parse_constraint(i) for i in constraints
95
        )
96
        super().__init__(sorted(parsed_constraints, key=lambda c: str(c)))
12✔
97

98
    def __str__(self) -> str:
12✔
99
        return " OR ".join(str(constraint) for constraint in self)
2✔
100

101
    def debug_hint(self) -> str:
12✔
102
        return str(self)
×
103

104
    @property
12✔
105
    def description(self) -> str:
12✔
106
        return str(sorted(str(c) for c in self))
1✔
107

108
    @classmethod
12✔
109
    def merge(cls, ics: Iterable[InterpreterConstraints]) -> InterpreterConstraints:
12✔
110
        return InterpreterConstraints(
×
111
            cls.merge_constraint_sets(tuple(str(requirement) for requirement in ic) for ic in ics)
112
        )
113

114
    @classmethod
12✔
115
    def merge_constraint_sets(
12✔
116
        cls, constraint_sets: Iterable[Iterable[str]]
117
    ) -> frozenset[Requirement]:
118
        """Given a collection of constraints sets, merge by ORing within each individual constraint
119
        set and ANDing across each distinct constraint set.
120

121
        For example, given `[["CPython>=2.7", "CPython<=3"], ["CPython==3.6.*"]]`, return
122
        `["CPython>=2.7,==3.6.*", "CPython<=3,==3.6.*"]`.
123
        """
124
        # A sentinel to indicate a requirement that is impossible to satisfy (i.e., one that
125
        # requires two different interpreter types).
126
        impossible = parse_constraint("IMPOSSIBLE")
1✔
127

128
        # Each element (a Set[ParsedConstraint]) will get ANDed. We use sets to deduplicate
129
        # identical top-level parsed constraint sets.
130

131
        # First filter out any empty constraint_sets, as those represent "no constraints", i.e.,
132
        # any interpreters are allowed, so omitting them has the logical effect of ANDing them with
133
        # the others, without having to deal with the vacuous case below.
134
        constraint_sets = [cs for cs in constraint_sets if cs]
1✔
135
        if not constraint_sets:
1✔
136
            return frozenset()
1✔
137

138
        parsed_constraint_sets: set[frozenset[Requirement]] = set()
1✔
139
        for constraint_set in constraint_sets:
1✔
140
            # Each element (a ParsedConstraint) will get ORed.
141
            parsed_constraint_set = frozenset(
1✔
142
                parse_constraint(constraint) for constraint in constraint_set
143
            )
144
            parsed_constraint_sets.add(parsed_constraint_set)
1✔
145

146
        if len(parsed_constraint_sets) == 1:
1✔
147
            return next(iter(parsed_constraint_sets))
1✔
148

149
        def and_constraints(parsed_requirements: Sequence[Requirement]) -> Requirement:
1✔
150
            assert len(parsed_requirements) > 0, "At least one `Requirement` must be supplied."
1✔
151
            expected_name = parsed_requirements[0].name
1✔
152
            current_requirement_specifier = parsed_requirements[0].specifier
1✔
153
            for requirement in parsed_requirements[1:]:
1✔
154
                if requirement.name != expected_name:
1✔
155
                    return impossible
1✔
156
                current_requirement_specifier &= requirement.specifier
1✔
157
            return Requirement(f"{expected_name}{current_requirement_specifier}")
1✔
158

159
        ored_constraints = (
1✔
160
            and_constraints(constraints_product)
161
            for constraints_product in itertools.product(*parsed_constraint_sets)
162
        )
163
        ret = frozenset(cs for cs in ored_constraints if cs != impossible)
1✔
164
        if not ret:
1✔
165
            # There are no possible combinations.
166
            attempted_str = " AND ".join(f"({' OR '.join(cs)})" for cs in constraint_sets)
1✔
167
            raise ValueError(
1✔
168
                softwrap(
169
                    f"""
170
                    These interpreter constraints cannot be merged, as they require
171
                    conflicting interpreter types: {attempted_str}
172
                    """
173
                )
174
            )
175
        return ret
1✔
176

177
    @classmethod
12✔
178
    def create_from_targets(
12✔
179
        cls, targets: Iterable[Target], python_setup: PythonSetup
180
    ) -> InterpreterConstraints | None:
181
        """Returns merged InterpreterConstraints for the given Targets.
182

183
        If none of the given Targets have InterpreterConstraintsField, returns None.
184

185
        NB: Because Python targets validate that they have ICs which are a subset of their
186
        dependencies, merging constraints like this is only necessary when you are _mixing_ code
187
        which might not have any interdependencies, such as when you're merging unrelated roots.
188
        """
189
        fields = [
×
190
            tgt[InterpreterConstraintsField]
191
            for tgt in targets
192
            if tgt.has_field(InterpreterConstraintsField)
193
        ]
194
        if not fields:
×
195
            return None
×
196
        return cls.create_from_compatibility_fields(fields, python_setup)
×
197

198
    @classmethod
12✔
199
    def create_from_compatibility_fields(
12✔
200
        cls, fields: Iterable[InterpreterConstraintsField], python_setup: PythonSetup
201
    ) -> InterpreterConstraints:
202
        """Returns merged InterpreterConstraints for the given `InterpreterConstraintsField`s.
203

204
        NB: Because Python targets validate that they have ICs which are a subset of their
205
        dependencies, merging constraints like this is only necessary when you are _mixing_ code
206
        which might not have any inter-dependencies, such as when you're merging un-related roots.
207
        """
208
        constraint_sets = {field.value_or_global_default(python_setup) for field in fields}
1✔
209
        # This will OR within each field and AND across fields.
210
        merged_constraints = cls.merge_constraint_sets(constraint_sets)
1✔
211
        return InterpreterConstraints(merged_constraints)
1✔
212

213
    @classmethod
12✔
214
    def group_field_sets_by_constraints(
12✔
215
        cls, field_sets: Iterable[_FS], python_setup: PythonSetup
216
    ) -> FrozenDict[InterpreterConstraints, tuple[_FS, ...]]:
217
        results = defaultdict(set)
1✔
218
        for fs in field_sets:
1✔
219
            constraints = cls.create_from_compatibility_fields(
1✔
220
                [fs.interpreter_constraints], python_setup
221
            )
222
            results[constraints].add(fs)
1✔
223
        return FrozenDict(
1✔
224
            {
225
                constraints: tuple(sorted(field_sets, key=lambda fs: fs.address))
226
                for constraints, field_sets in sorted(results.items())
227
            }
228
        )
229

230
    def generate_pex_arg_list(self) -> list[str]:
12✔
UNCOV
231
        args = []
1✔
UNCOV
232
        for constraint in self:
1✔
UNCOV
233
            args.extend(["--interpreter-constraint", str(constraint)])
1✔
UNCOV
234
        return args
1✔
235

236
    def _valid_patch_versions(self, major: int, minor: int) -> Iterator[int]:
12✔
237
        for p in range(0, _PATCH_VERSION_UPPER_BOUND + 1):
12✔
238
            for req in self:
12✔
239
                if req.specifier.contains(f"{major}.{minor}.{p}"):
12✔
240
                    yield p
12✔
241

242
    def _includes_version(self, major: int, minor: int) -> bool:
12✔
243
        return any(True for _ in self._valid_patch_versions(major, minor))
2✔
244

245
    def includes_python2(self) -> bool:
12✔
246
        """Checks if any of the constraints include Python 2.
247

248
        This will return True even if the code works with Python 3 too, so long as at least one of
249
        the constraints works with Python 2.
250
        """
251
        return self._includes_version(2, 7)
1✔
252

253
    def minimum_python_version(self, interpreter_universe: Iterable[str]) -> str | None:
12✔
254
        """Find the lowest major.minor Python version that will work with these constraints.
255

256
        The constraints may also be compatible with later versions; this is the lowest version that
257
        still works.
258
        """
259
        for major, minor in sorted(_major_minor_to_int(s) for s in interpreter_universe):
2✔
260
            if self._includes_version(major, minor):
2✔
261
                return f"{major}.{minor}"
2✔
262
        return None
1✔
263

264
    def snap_to_minimum(self, interpreter_universe: Iterable[str]) -> InterpreterConstraints | None:
12✔
265
        """Snap to the lowest Python major.minor version that works with these constraints.
266

267
        Will exclude patch versions that are expressly incompatible.
268
        """
269
        for major, minor in sorted(_major_minor_to_int(s) for s in interpreter_universe):
1✔
270
            for p in range(0, _PATCH_VERSION_UPPER_BOUND + 1):
1✔
271
                for req in self:
1✔
272
                    if req.specifier.contains(f"{major}.{minor}.{p}"):
1✔
273
                        # We've found the minimum major.minor that is compatible.
274
                        req_strs = [f"{req.name}=={major}.{minor}.*"]
1✔
275
                        # Now find any patches within that major.minor that we must exclude.
276
                        invalid_patches = sorted(
1✔
277
                            set(range(0, _PATCH_VERSION_UPPER_BOUND + 1))
278
                            - set(self._valid_patch_versions(major, minor))
279
                        )
280
                        req_strs.extend(f"!={major}.{minor}.{p}" for p in invalid_patches)
1✔
281
                        req_str = ",".join(req_strs)
1✔
282
                        snapped = parse_constraint(req_str)
1✔
283
                        return InterpreterConstraints([snapped])
1✔
284
        return None
1✔
285

286
    def _requires_python3_version_or_newer(
12✔
287
        self, *, allowed_versions: Iterable[str], prior_version: str
288
    ) -> bool:
289
        if not self:
1✔
290
            return False
1✔
291
        patch_versions = list(reversed(range(0, _PATCH_VERSION_UPPER_BOUND)))
1✔
292
        # We only look at the prior Python release. For example, consider Python 3.8+
293
        # looking at 3.7. If using something like `>=3.5`, Py37 will be included.
294
        # `==3.6.*,!=3.7.*,==3.8.*` is unlikely, and even that will work correctly as
295
        # it's an invalid constraint so setuptools returns False always. `['==2.7.*', '==3.8.*']`
296
        # will fail because not every single constraint is exclusively 3.8.
297
        prior_versions = [f"{prior_version}.{p}" for p in patch_versions]
1✔
298
        allowed_versions = [
1✔
299
            f"{major_minor}.{p}" for major_minor in allowed_versions for p in patch_versions
300
        ]
301

302
        def valid_constraint(constraint: Requirement) -> bool:
1✔
303
            if any(constraint.specifier.contains(prior) for prior in prior_versions):
1✔
304
                return False
1✔
305
            if not any(constraint.specifier.contains(allowed) for allowed in allowed_versions):
1✔
306
                return False
1✔
307
            return True
1✔
308

309
        return all(valid_constraint(c) for c in self)
1✔
310

311
    def requires_python38_or_newer(self, interpreter_universe: Iterable[str]) -> bool:
12✔
312
        """Checks if the constraints are all for Python 3.8+.
313

314
        This will return False if Python 3.8 is allowed, but prior versions like 3.7 are also
315
        allowed.
316
        """
317
        py38_and_later = [
1✔
318
            interp for interp in interpreter_universe if _major_minor_to_int(interp) >= (3, 8)
319
        ]
320
        return self._requires_python3_version_or_newer(
1✔
321
            allowed_versions=py38_and_later, prior_version="3.7"
322
        )
323

324
    def to_poetry_constraint(self) -> str:
12✔
325
        specifiers = []
1✔
326
        wildcard_encountered = False
1✔
327
        for constraint in self:
1✔
328
            specifier = str(constraint.specifier)
1✔
329
            if specifier:
1✔
330
                specifiers.append(specifier)
1✔
331
            else:
332
                wildcard_encountered = True
1✔
333
        if not specifiers or wildcard_encountered:
1✔
334
            return "*"
1✔
335
        return " || ".join(specifiers)
1✔
336

337
    def enumerate_python_versions(
12✔
338
        self, interpreter_universe: Iterable[str]
339
    ) -> FrozenOrderedSet[tuple[int, int, int]]:
340
        """Return a set of all plausible (major, minor, patch) tuples for all Python 2.7/3.x in the
341
        specified interpreter universe that matches this set of interpreter constraints.
342

343
        This also validates our assumptions around the `interpreter_universe`:
344

345
        - Python 2.7 is the only Python 2 version in the universe, if at all.
346
        - Python 3 is the last major release of Python, which the core devs have committed to in
347
          public several times.
348
        """
349
        if not self:
12✔
350
            return FrozenOrderedSet()
1✔
351

352
        minors = []
12✔
353
        for major_minor in interpreter_universe:
12✔
354
            major, minor = _major_minor_to_int(major_minor)
12✔
355
            if major == 2:
12✔
356
                if minor != 7:
12✔
357
                    raise AssertionError(
1✔
358
                        softwrap(
359
                            f"""
360
                            Unexpected value in `[python].interpreter_versions_universe`:
361
                            {major_minor}. Expected the only Python 2 value to be '2.7', given that
362
                            all other versions are unmaintained or do not exist.
363
                            """
364
                        )
365
                    )
366
                minors.append((2, minor))
12✔
367
            elif major == 3:
12✔
368
                minors.append((3, minor))
12✔
369
            else:
370
                raise AssertionError(
1✔
371
                    softwrap(
372
                        f"""
373
                        Unexpected value in `[python].interpreter_versions_universe`:
374
                        {major_minor}. Expected to only include '2.7' and/or Python 3 versions,
375
                        given that Python 3 will be the last major Python version. Please open an
376
                        issue at https://github.com/pantsbuild/pants/issues/new if this is no longer
377
                        true.
378
                        """
379
                    )
380
                )
381

382
        valid_patches = FrozenOrderedSet(
12✔
383
            (major, minor, patch)
384
            for (major, minor) in sorted(minors)
385
            for patch in self._valid_patch_versions(major, minor)
386
        )
387

388
        if not valid_patches:
12✔
389
            raise ValueError(
1✔
390
                softwrap(
391
                    f"""
392
                    The interpreter constraints `{self}` are not compatible with any of the
393
                    interpreter versions from `[python].interpreter_versions_universe`.
394

395
                    Please either change these interpreter constraints or update the
396
                    `interpreter_versions_universe` to include the interpreters set in these
397
                    constraints. Run `{bin_name()} help-advanced python` for more information on the
398
                    `interpreter_versions_universe` option.
399
                    """
400
                )
401
            )
402

403
        return valid_patches
12✔
404

405
    def contains(self, other: InterpreterConstraints, interpreter_universe: Iterable[str]) -> bool:
12✔
406
        """Returns True if the `InterpreterConstraints` specified in `other` is a subset of these
407
        `InterpreterConstraints`.
408

409
        This is restricted to the set of minor Python versions specified in `universe`.
410
        """
411
        if self == other:
3✔
412
            return True
2✔
413
        this = self.enumerate_python_versions(interpreter_universe)
3✔
414
        that = other.enumerate_python_versions(interpreter_universe)
3✔
415
        return this.issuperset(that)
3✔
416

417
    def partition_into_major_minor_versions(
12✔
418
        self, interpreter_universe: Iterable[str]
419
    ) -> tuple[str, ...]:
420
        """Return all the valid major.minor versions, e.g. `('2.7', '3.6')`."""
421
        result: OrderedSet[str] = OrderedSet()
11✔
422
        for major, minor, _ in self.enumerate_python_versions(interpreter_universe):
11✔
423
            result.add(f"{major}.{minor}")
11✔
424
        return tuple(result)
11✔
425

426
    def major_minor_version_when_single_and_entire(self) -> None | tuple[int, int]:
12✔
427
        """Returns the (major, minor) version that these constraints cover, if they cover all of
428
        exactly one major minor version, without rules about patch versions.
429

430
        This is a best effort function, e.g. for using during inference that can be overridden.
431

432
        Examples:
433

434
        All of these return (3, 9): `==3.9.*`, `CPython==3.9.*`, `>=3.9,<3.10`, `<3.10,>=3.9`
435

436
        All of these return None:
437

438
        - `==3.9.10`: restricted to a single patch version
439
        - `==3.9`: restricted to a single patch version (0, implicitly)
440
        - `==3.9.*,!=3.9.2`: excludes a patch
441
        - `>=3.9,<3.11`: more than one major version
442
        - `>=3.9,<3.11,!=3.10`: too complicated to understand it only includes 3.9
443
        - more than one requirement in the list: too complicated
444
        """
445

446
        try:
1✔
447
            return _major_minor_version_when_single_and_entire(self)
1✔
448
        except _NonSimpleMajorMinor:
1✔
449
            return None
1✔
450

451

452
def _major_minor_to_int(major_minor: str) -> tuple[int, int]:
12✔
453
    return tuple(int(x) for x in major_minor.split(".", maxsplit=1))  # type: ignore[return-value]
12✔
454

455

456
class _NonSimpleMajorMinor(Exception):
12✔
457
    pass
12✔
458

459

460
_ANY_PATCH_VERSION = re.compile(r"^(?P<major>\d+)\.(?P<minor>\d+)(?P<any_patch>\.\*)?$")
12✔
461

462

463
def _parse_simple_version(version: str, require_any_patch: bool) -> tuple[int, int]:
12✔
464
    match = _ANY_PATCH_VERSION.fullmatch(version)
1✔
465
    if match is None or (require_any_patch and match.group("any_patch") is None):
1✔
466
        raise _NonSimpleMajorMinor()
1✔
467

468
    return int(match.group("major")), int(match.group("minor"))
1✔
469

470

471
def _major_minor_version_when_single_and_entire(ics: InterpreterConstraints) -> tuple[int, int]:
12✔
472
    if len(ics) != 1:
1✔
473
        raise _NonSimpleMajorMinor()
1✔
474

475
    req = next(iter(ics))
1✔
476

477
    just_cpython = req.name == "CPython" and not req.extras and not req.marker
1✔
478
    if not just_cpython:
1✔
479
        raise _NonSimpleMajorMinor()
×
480

481
    # ==major.minor or ==major.minor.*
482
    if len(req.specifier) == 1:
1✔
483
        specifier = next(iter(req.specifier))
1✔
484
        if specifier.operator != "==":
1✔
485
            raise _NonSimpleMajorMinor()
1✔
486

487
        return _parse_simple_version(specifier.version, require_any_patch=True)
1✔
488

489
    # >=major.minor,<major.(minor+1)
490
    if len(req.specifier) == 2:
1✔
491
        specifiers = sorted(req.specifier, key=lambda s: s.version)
1✔
492
        operator_lo, version_lo = (specifiers[0].operator, specifiers[0].version)
1✔
493
        operator_hi, version_hi = (specifiers[1].operator, specifiers[1].version)
1✔
494

495
        if operator_lo != ">=":
1✔
496
            # if the lo operator isn't >=, they might be in the wrong order (or, if not, the check
497
            # below will catch them)
498
            operator_lo, operator_hi = operator_hi, operator_lo
1✔
499
            version_lo, version_hi = version_hi, version_lo
1✔
500

501
        if operator_lo != ">=" and operator_hi != "<":
1✔
502
            raise _NonSimpleMajorMinor()
1✔
503

504
        major_lo, minor_lo = _parse_simple_version(version_lo, require_any_patch=False)
1✔
505
        major_hi, minor_hi = _parse_simple_version(version_hi, require_any_patch=False)
1✔
506

507
        if major_lo == major_hi and minor_lo + 1 == minor_hi:
1✔
508
            return major_lo, minor_lo
1✔
509

510
        raise _NonSimpleMajorMinor()
1✔
511

512
    # anything else we don't understand
513
    raise _NonSimpleMajorMinor()
1✔
514

515

516
@memoized
12✔
517
def _warn_on_python2_usage_in_interpreter_constraints(
12✔
518
    interpreter_constraints: tuple[str, ...], *, description_of_origin: str
519
) -> None:
520
    ics = InterpreterConstraints(interpreter_constraints)
×
521
    if ics.includes_python2():
×
522
        logger.warning(
×
523
            f"The Python interpreter constraints from {description_of_origin} includes Python 2.x as a selected Python version. "
524
            "Please note that Pants will no longer be proactively tested with Python 2.x starting with Pants v2.24.x because "
525
            "Python 2 support ended on 1 January 2020. Please consider upgrading to Python 3.x for your code."
526
        )
527

528

529
def warn_on_python2_usage_in_interpreter_constraints(
12✔
530
    interpreter_constraints: Iterable[str], *, description_of_origin: str
531
) -> None:
532
    _warn_on_python2_usage_in_interpreter_constraints(
×
533
        tuple(interpreter_constraints), description_of_origin=description_of_origin
534
    )
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