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

pantsbuild / pants / 19015773527

02 Nov 2025 05:33PM UTC coverage: 17.872% (-62.4%) from 80.3%
19015773527

Pull #22816

github

web-flow
Merge a12d75757 into 6c024e162
Pull Request #22816: Update Pants internal Python to 3.14

4 of 5 new or added lines in 3 files covered. (80.0%)

28452 existing lines in 683 files now uncovered.

9831 of 55007 relevant lines covered (17.87%)

0.18 hits per line

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

0.0
/src/python/pants/backend/python/util_rules/package_dists.py
1
# Copyright 2019 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

UNCOV
4
from __future__ import annotations
×
5

UNCOV
6
import itertools
×
UNCOV
7
import logging
×
UNCOV
8
import os
×
UNCOV
9
import pickle
×
UNCOV
10
from abc import ABC, abstractmethod
×
UNCOV
11
from collections import defaultdict
×
UNCOV
12
from collections.abc import Mapping
×
UNCOV
13
from dataclasses import dataclass
×
UNCOV
14
from functools import partial
×
UNCOV
15
from pathlib import PurePath
×
UNCOV
16
from typing import Any, DefaultDict, cast
×
17

UNCOV
18
from pants.backend.python.macros.python_artifact import PythonArtifact
×
UNCOV
19
from pants.backend.python.subsystems.setup import PythonSetup
×
UNCOV
20
from pants.backend.python.subsystems.setup_py_generation import SetupPyGeneration
×
UNCOV
21
from pants.backend.python.target_types import (
×
22
    BuildBackendEnvVarsField,
23
    GenerateSetupField,
24
    LongDescriptionPathField,
25
    PythonDistributionEntryPointsField,
26
    PythonDistributionOutputPathField,
27
    PythonGeneratingSourcesBase,
28
    PythonProvidesField,
29
    PythonRequirementsField,
30
    PythonSourceField,
31
    ResolvedPythonDistributionEntryPoints,
32
    ResolvePythonDistributionEntryPointsRequest,
33
    SDistConfigSettingsField,
34
    SDistField,
35
    WheelConfigSettingsField,
36
    WheelField,
37
)
UNCOV
38
from pants.backend.python.target_types_rules import (
×
39
    SetupPyError,
40
    resolve_python_distribution_entry_points,
41
)
UNCOV
42
from pants.backend.python.util_rules.dists import (
×
43
    BuildSystemRequest,
44
    DistBuildRequest,
45
    distutils_repr,
46
    find_build_system,
47
)
UNCOV
48
from pants.backend.python.util_rules.dists import rules as dists_rules
×
UNCOV
49
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
×
UNCOV
50
from pants.backend.python.util_rules.pex import Pex
×
UNCOV
51
from pants.backend.python.util_rules.pex_requirements import PexRequirements
×
UNCOV
52
from pants.backend.python.util_rules.python_sources import (
×
53
    PythonSourceFilesRequest,
54
    prepare_python_sources,
55
)
UNCOV
56
from pants.backend.python.util_rules.python_sources import rules as python_sources_rules
×
UNCOV
57
from pants.backend.python.util_rules.python_sources import strip_python_sources
×
UNCOV
58
from pants.base.glob_match_error_behavior import GlobMatchErrorBehavior
×
UNCOV
59
from pants.base.specs import AncestorGlobSpec, RawSpecs
×
UNCOV
60
from pants.core.target_types import FileSourceField, ResourceSourceField
×
UNCOV
61
from pants.core.util_rules.env_vars import environment_vars_subset
×
UNCOV
62
from pants.engine.addresses import Address, UnparsedAddressInputs
×
UNCOV
63
from pants.engine.collection import Collection, DeduplicatedCollection
×
UNCOV
64
from pants.engine.env_vars import EnvironmentVars, EnvironmentVarsRequest
×
UNCOV
65
from pants.engine.environment import EnvironmentName
×
UNCOV
66
from pants.engine.fs import (
×
67
    AddPrefix,
68
    CreateDigest,
69
    Digest,
70
    DigestContents,
71
    DigestSubset,
72
    FileContent,
73
    MergeDigests,
74
    PathGlobs,
75
)
UNCOV
76
from pants.engine.internals.graph import resolve_targets
×
UNCOV
77
from pants.engine.internals.graph import transitive_targets as transitive_targets_get
×
UNCOV
78
from pants.engine.intrinsics import add_prefix, create_digest, get_digest_contents, merge_digests
×
UNCOV
79
from pants.engine.rules import collect_rules, concurrently, implicitly, rule
×
UNCOV
80
from pants.engine.target import (
×
81
    Dependencies,
82
    DependenciesRequest,
83
    InvalidFieldException,
84
    SourcesField,
85
    Target,
86
    Targets,
87
    TransitiveTargetsRequest,
88
    targets_with_sources_types,
89
)
UNCOV
90
from pants.engine.unions import UnionMembership, union
×
UNCOV
91
from pants.source.source_root import (
×
92
    SourceRootRequest,
93
    SourceRootsRequest,
94
    get_optional_source_root,
95
    get_source_roots,
96
)
UNCOV
97
from pants.util.docutil import doc_url
×
UNCOV
98
from pants.util.frozendict import FrozenDict
×
UNCOV
99
from pants.util.logging import LogLevel
×
UNCOV
100
from pants.util.memo import memoized_property
×
UNCOV
101
from pants.util.ordered_set import FrozenOrderedSet, OrderedSet
×
UNCOV
102
from pants.util.strutil import softwrap
×
103

UNCOV
104
logger = logging.getLogger(__name__)
×
105

106

UNCOV
107
class InvalidSetupPyArgs(SetupPyError):
×
108
    """Indicates invalid arguments to setup.py."""
109

110

UNCOV
111
class TargetNotExported(SetupPyError):
×
112
    """Indicates a target that was expected to be exported is not."""
113

114

UNCOV
115
class OwnershipError(SetupPyError):
×
116
    """An error related to target ownership calculation."""
117

UNCOV
118
    def __init__(self, msg: str):
×
119
        super().__init__(
×
120
            softwrap(
121
                f"""
122
                {msg} See {doc_url("docs/python/overview/building-distributions")} for
123
                how python_sources targets are mapped to distributions.
124
                """
125
            )
126
        )
127

128

UNCOV
129
class NoOwnerError(OwnershipError):
×
130
    """Indicates an exportable target has no owning exported target."""
131

132

UNCOV
133
class AmbiguousOwnerError(OwnershipError):
×
134
    """Indicates an exportable target has more than one owning exported target."""
135

136

UNCOV
137
@dataclass(frozen=True)
×
UNCOV
138
class ExportedTarget:
×
139
    """A target that explicitly exports a setup.py artifact, using a `provides=` stanza.
140

141
    The code provided by this artifact can be from this target or from any targets it owns.
142
    """
143

UNCOV
144
    target: Target  # In practice, a PythonDistribution.
×
145

UNCOV
146
    @property
×
UNCOV
147
    def provides(self) -> PythonArtifact:
×
148
        return self.target[PythonProvidesField].value
×
149

150

UNCOV
151
@dataclass(frozen=True)
×
UNCOV
152
class DependencyOwner:
×
153
    """An ExportedTarget in its role as an owner of other targets.
154

155
    We need this type to prevent rule ambiguities when computing the list of targets owned by an
156
    ExportedTarget (which involves going from ExportedTarget -> dep -> owner (which is itself an
157
    ExportedTarget) and checking if owner is the original ExportedTarget).
158
    """
159

UNCOV
160
    exported_target: ExportedTarget
×
161

162

UNCOV
163
@dataclass(frozen=True)
×
UNCOV
164
class OwnedDependency:
×
165
    """A target that is owned by some ExportedTarget.
166

167
    Code in this target is published in the owner's distribution.
168

169
    The owner of a target T is T's closest filesystem ancestor among the python_distribution
170
    targets that directly or indirectly depend on it (including T itself).
171
    """
172

UNCOV
173
    target: Target
×
174

175

UNCOV
176
class OwnedDependencies(Collection[OwnedDependency]):
×
UNCOV
177
    pass
×
178

179

UNCOV
180
class ExportedTargetRequirements(DeduplicatedCollection[str]):
×
181
    """The requirements of an ExportedTarget.
182

183
    Includes:
184
    - The "normal" 3rdparty requirements of the ExportedTarget and all targets it owns.
185
    - The published versions of any other ExportedTargets it depends on.
186
    """
187

UNCOV
188
    sort_input = True
×
189

190

UNCOV
191
@dataclass(frozen=True)
×
UNCOV
192
class DistBuildSources:
×
193
    """The source-root-stripped sources required to build a distribution with a generated setup.py.
194

195
    Includes some information derived from analyzing the source, namely the packages, namespace
196
    packages and resource files in the source.
197
    """
198

UNCOV
199
    digest: Digest
×
UNCOV
200
    packages: tuple[str, ...]
×
UNCOV
201
    namespace_packages: tuple[str, ...]
×
UNCOV
202
    package_data: tuple[PackageDatum, ...]
×
203

204

UNCOV
205
@dataclass(frozen=True)
×
UNCOV
206
class DistBuildChrootRequest:
×
207
    """A request to create a chroot for building a dist in."""
208

UNCOV
209
    exported_target: ExportedTarget
×
UNCOV
210
    interpreter_constraints: InterpreterConstraints
×
211

212

UNCOV
213
@dataclass(frozen=True)
×
UNCOV
214
class SetupKwargs:
×
215
    """The keyword arguments to the `setup()` function in the generated `setup.py`."""
216

UNCOV
217
    _pickled_bytes: bytes
×
UNCOV
218
    _overwrite_banned_keys: tuple[str, ...]
×
219

UNCOV
220
    def __init__(
×
221
        self,
222
        kwargs: Mapping[str, Any],
223
        *,
224
        address: Address,
225
        _allow_banned_keys: bool = False,
226
        _overwrite_banned_keys: tuple[str, ...] = (),
227
    ) -> None:
228
        super().__init__()
×
229
        if "name" not in kwargs:
×
230
            raise InvalidSetupPyArgs(
×
231
                f"Missing a `name` kwarg in the `provides` field for {address}."
232
            )
233
        if "version" not in kwargs:
×
234
            raise InvalidSetupPyArgs(
×
235
                f"Missing a `version` kwarg in the `provides` field for {address}."
236
            )
237

238
        if not _allow_banned_keys:
×
239
            for arg in {
×
240
                "data_files",
241
                "install_requires",
242
                "namespace_packages",
243
                "package_data",
244
                "package_dir",
245
                "packages",
246
            }:
247
                if arg in kwargs:
×
248
                    raise ValueError(
×
249
                        softwrap(
250
                            f"""
251
                            {arg} cannot be set in the `provides` field for {address}, but it was
252
                            set to {kwargs[arg]}. Pants will dynamically set the value for you.
253
                            """
254
                        )
255
                    )
256
        object.__setattr__(
×
257
            self, "_overwrite_banned_keys", _overwrite_banned_keys if _overwrite_banned_keys else ()
258
        )
259
        # We serialize with `pickle` so that is hashable. We don't use `FrozenDict` because it
260
        # would require that all values are immutable, and we may have lists and dictionaries as
261
        # values. It's too difficult/clunky to convert those all, then to convert them back out of
262
        # `FrozenDict`. We don't use JSON because it does not preserve data types like `tuple`.
263
        object.__setattr__(
×
264
            self,
265
            "_pickled_bytes",
266
            pickle.dumps(dict(sorted(kwargs.items())), protocol=4),
267
        )
268

UNCOV
269
    @memoized_property
×
UNCOV
270
    def kwargs(self) -> dict[str, Any]:
×
UNCOV
271
        return cast(dict[str, Any], pickle.loads(self._pickled_bytes))
×
272

UNCOV
273
    @property
×
UNCOV
274
    def name(self) -> str:
×
275
        return cast(str, self.kwargs["name"])
×
276

UNCOV
277
    @property
×
UNCOV
278
    def version(self) -> str:
×
279
        return cast(str, self.kwargs["version"])
×
280

281

282
# Note: This exists as a hook for additional logic for `setup()` kwargs, e.g. for plugin authors.
UNCOV
283
@union(in_scope_types=[EnvironmentName])
×
UNCOV
284
@dataclass(frozen=True)
×
UNCOV
285
class SetupKwargsRequest(ABC):
×
286
    """A request to allow setting the kwargs passed to the `setup()` function.
287

288
    By default, Pants will pass the kwargs provided in the BUILD file unchanged. To customize this
289
    behavior, subclass `SetupKwargsRequest`, register the rule `UnionRule(SetupKwargsRequest,
290
    MyCustomSetupKwargsRequest)`, and add a rule that takes your subclass as a parameter and returns
291
    `SetupKwargs`.
292
    """
293

UNCOV
294
    target: Target
×
295

UNCOV
296
    @classmethod
×
UNCOV
297
    @abstractmethod
×
UNCOV
298
    def is_applicable(cls, target: Target) -> bool:
×
299
        """Whether the kwargs implementation should be used for this target or not."""
300

UNCOV
301
    @property
×
UNCOV
302
    def explicit_kwargs(self) -> dict[str, Any]:
×
303
        # We return a dict copy of the underlying FrozenDict, because the caller expects a
304
        # dict (and we have documented as much).
305
        return dict(self.target[PythonProvidesField].value.kwargs)
×
306

307

UNCOV
308
@rule(polymorphic=True)
×
UNCOV
309
async def get_setup_kwargs(req: SetupKwargsRequest, env_name: EnvironmentName) -> SetupKwargs:
×
310
    raise NotImplementedError()
×
311

312

UNCOV
313
class FinalizedSetupKwargs(SetupKwargs):
×
314
    """The final kwargs used for the `setup()` function, after Pants added requirements and sources
315
    information."""
316

UNCOV
317
    def __init__(self, kwargs: Mapping[str, Any], *, address: Address) -> None:
×
318
        super().__init__(kwargs, address=address, _allow_banned_keys=True)
×
319

320

UNCOV
321
@dataclass(frozen=True)
×
UNCOV
322
class DistBuildChroot:
×
323
    """A chroot containing PEP 517 build setup and the sources it operates on."""
324

UNCOV
325
    digest: Digest
×
UNCOV
326
    working_directory: str  # Path to dir within digest.
×
327

328

UNCOV
329
def validate_commands(commands: tuple[str, ...]):
×
330
    # We rely on the dist dir being the default, so we know where to find the created dists.
UNCOV
331
    if "--dist-dir" in commands or "-d" in commands:
×
UNCOV
332
        raise InvalidSetupPyArgs(
×
333
            softwrap(
334
                """
335
                Cannot set --dist-dir/-d in setup.py args. To change where dists
336
                are written, use the global --pants-distdir option.
337
                """
338
            )
339
        )
340
    # We don't allow publishing via setup.py, as we don't want the setup.py running rule,
341
    # which is not a @goal_rule, to side effect (plus, we'd need to ensure that publishing
342
    # happens in dependency order).  Note that `upload` and `register` were removed in
343
    # setuptools 42.0.0, in favor of Twine, but we still check for them in case the user modified
344
    # the default version used by our Setuptools subsystem.
UNCOV
345
    if "upload" in commands or "register" in commands:
×
UNCOV
346
        raise InvalidSetupPyArgs("Cannot use the `upload` or `register` setup.py commands.")
×
347

348

UNCOV
349
class NoDistTypeSelected(ValueError):
×
UNCOV
350
    pass
×
351

352

UNCOV
353
@union(in_scope_types=[EnvironmentName])
×
UNCOV
354
@dataclass(frozen=True)
×
UNCOV
355
class DistBuildEnvironmentRequest:
×
UNCOV
356
    target_addresses: tuple[Address, ...]
×
UNCOV
357
    interpreter_constraints: InterpreterConstraints
×
358

UNCOV
359
    @classmethod
×
UNCOV
360
    def is_applicable(cls, tgt: Target) -> bool:
×
361
        # Union members should override.
362
        return False
×
363

364

UNCOV
365
@dataclass(frozen=True)
×
UNCOV
366
class DistBuildEnvironment:
×
367
    """Various extra information that might be needed to build a dist."""
368

UNCOV
369
    extra_build_time_requirements: tuple[Pex, ...]
×
UNCOV
370
    extra_build_time_inputs: Digest
×
371

372

UNCOV
373
@rule(polymorphic=True)
×
UNCOV
374
async def create_dist_build_environment(
×
375
    req: DistBuildEnvironmentRequest, env_name: EnvironmentName
376
) -> DistBuildEnvironment:
377
    raise NotImplementedError()
×
378

379

UNCOV
380
async def create_dist_build_request(
×
381
    dist_target_address: Address,
382
    python_setup: PythonSetup,
383
    union_membership: UnionMembership,
384
    validate_wheel_sdist: bool = True,
385
) -> DistBuildRequest:
386
    """Create a DistBuildRequest for a `python_distribution`.
387

388
    This is a separate helper function so that editable wheel builds can share setup logic with the
389
    standard wheel/sdist builds.
390
    """
391

392
    transitive_targets = await transitive_targets_get(
×
393
        TransitiveTargetsRequest([dist_target_address]), **implicitly()
394
    )
395
    exported_target = ExportedTarget(transitive_targets.roots[0])
×
396

397
    dist_tgt = exported_target.target
×
398
    wheel = dist_tgt.get(WheelField).value
×
399
    sdist = dist_tgt.get(SDistField).value
×
400
    if validate_wheel_sdist and not wheel and not sdist:
×
401
        raise NoDistTypeSelected(
×
402
            softwrap(
403
                f"""
404
                In order to package {dist_tgt.address.spec} at least one of {WheelField.alias!r} or
405
                {SDistField.alias!r} must be `True`.
406
                """
407
            )
408
        )
409

410
    wheel_config_settings = dist_tgt.get(WheelConfigSettingsField).value or FrozenDict()
×
411
    sdist_config_settings = dist_tgt.get(SDistConfigSettingsField).value or FrozenDict()
×
412
    backend_env_vars = dist_tgt.get(BuildBackendEnvVarsField).value
×
413
    if backend_env_vars:
×
414
        extra_build_time_env = await environment_vars_subset(
×
415
            EnvironmentVarsRequest(sorted(backend_env_vars)), **implicitly()
416
        )
417
    else:
418
        extra_build_time_env = EnvironmentVars()
×
419

420
    interpreter_constraints = InterpreterConstraints.create_from_targets(
×
421
        transitive_targets.closure, python_setup
422
    ) or InterpreterConstraints(python_setup.interpreter_constraints)
423
    chroot = await generate_chroot(
×
424
        DistBuildChrootRequest(
425
            exported_target,
426
            interpreter_constraints=interpreter_constraints,
427
        ),
428
        **implicitly(),
429
    )
430

431
    # Find the source roots for the build-time 1stparty deps (e.g., deps of setup.py).
432
    optional_dist_source_root, source_roots_result = await concurrently(
×
433
        get_optional_source_root(SourceRootRequest.for_target(dist_tgt), **implicitly()),
434
        get_source_roots(
435
            SourceRootsRequest(
436
                files=[],
437
                dirs={
438
                    PurePath(tgt.address.spec_path)
439
                    for tgt in transitive_targets.closure
440
                    if tgt.has_field(PythonSourceField) or tgt.has_field(ResourceSourceField)
441
                },
442
            )
443
        ),
444
    )
445

446
    # We have to get `dist_source_root` independently from the other source roots because it is
447
    # not a `python_source` target, and because `source_roots_result.path_to_root` is already sorted.
448
    dist_source_root = (
×
449
        optional_dist_source_root.source_root.path if optional_dist_source_root.source_root else "."
450
    )
451
    source_roots = tuple(sorted({sr.path for sr in source_roots_result.path_to_root.values()}))
×
452

453
    # Get any extra build-time environment (e.g., native extension requirements).
454
    build_env_requests = []
×
455
    build_env_request_types = union_membership.get(DistBuildEnvironmentRequest)
×
456
    for build_env_request_type in build_env_request_types:
×
457
        if build_env_request_type.is_applicable(dist_tgt):
×
458
            build_env_requests.append(
×
459
                build_env_request_type(
460
                    tuple(tt.address for tt in transitive_targets.closure), interpreter_constraints
461
                )
462
            )
463

464
    build_envs = await concurrently(
×
465
        [
466
            create_dist_build_environment(
467
                **implicitly({build_env_request: DistBuildEnvironmentRequest})
468
            )
469
            for build_env_request in build_env_requests
470
        ]
471
    )
472
    extra_build_time_requirements = tuple(
×
473
        itertools.chain.from_iterable(
474
            build_env.extra_build_time_requirements for build_env in build_envs
475
        )
476
    )
477
    input_digest = await merge_digests(
×
478
        MergeDigests(
479
            [chroot.digest, *(build_env.extra_build_time_inputs for build_env in build_envs)]
480
        )
481
    )
482

483
    # We prefix the entire chroot, and run with this prefix as the cwd, so that we can capture
484
    # any changes setup made within it without also capturing other artifacts of the pex
485
    # process invocation.
486
    chroot_prefix = "chroot"
×
487
    working_directory = os.path.join(chroot_prefix, chroot.working_directory)
×
488
    prefixed_input = await add_prefix(AddPrefix(input_digest, chroot_prefix))
×
489
    build_system = await find_build_system(
×
490
        BuildSystemRequest(prefixed_input, working_directory), **implicitly()
491
    )
492
    output_path = dist_tgt.get(PythonDistributionOutputPathField).value
×
493
    assert output_path is not None, (
×
494
        "output_path should take a default string value if the user has not provided it."
495
    )
496

497
    return DistBuildRequest(
×
498
        build_system=build_system,
499
        interpreter_constraints=interpreter_constraints,
500
        build_wheel=wheel,
501
        build_sdist=sdist,
502
        input=prefixed_input,
503
        working_directory=working_directory,
504
        dist_source_root=dist_source_root,
505
        build_time_source_roots=source_roots,
506
        target_address_spec=exported_target.target.address.spec,
507
        wheel_config_settings=wheel_config_settings,
508
        sdist_config_settings=sdist_config_settings,
509
        extra_build_time_requirements=extra_build_time_requirements,
510
        extra_build_time_env=extra_build_time_env,
511
        output_path=output_path,
512
    )
513

514

UNCOV
515
SETUP_BOILERPLATE = """
×
516
# DO NOT EDIT THIS FILE -- AUTOGENERATED BY PANTS
517
# Target: {target_address_spec}
518

519
from setuptools import setup
520

521
setup(**{setup_kwargs_str})
522
"""
523

524

UNCOV
525
@rule
×
UNCOV
526
async def determine_explicitly_provided_setup_kwargs(
×
527
    exported_target: ExportedTarget, union_membership: UnionMembership
528
) -> SetupKwargs:
529
    target = exported_target.target
×
530
    setup_kwargs_requests = union_membership.get(SetupKwargsRequest)
×
531
    applicable_setup_kwargs_requests = tuple(
×
532
        request for request in setup_kwargs_requests if request.is_applicable(target)
533
    )
534

535
    # If no provided implementations, fall back to our default implementation that simply returns
536
    # what the user explicitly specified in the BUILD file.
537
    if not applicable_setup_kwargs_requests:
×
538
        return SetupKwargs(exported_target.provides.kwargs, address=target.address)
×
539

540
    if len(applicable_setup_kwargs_requests) > 1:
×
541
        possible_requests = sorted(plugin.__name__ for plugin in applicable_setup_kwargs_requests)
×
542
        raise ValueError(
×
543
            softwrap(
544
                f"""
545
                Multiple of the registered `SetupKwargsRequest`s can work on the target
546
                {target.address}, and it's ambiguous which to use: {possible_requests}
547

548
                Please activate fewer implementations, or make the classmethod `is_applicable()`
549
                more precise so that only one implementation is applicable for this target.
550
                """
551
            )
552
        )
553
    setup_kwargs_request_type = tuple(applicable_setup_kwargs_requests)[0]
×
554
    setup_kwargs_request: SetupKwargsRequest = setup_kwargs_request_type(target)  # type: ignore[abstract]
×
555
    return await get_setup_kwargs(**implicitly({setup_kwargs_request: SetupKwargsRequest}))
×
556

557

UNCOV
558
@dataclass(frozen=True)
×
UNCOV
559
class GenerateSetupPyRequest:
×
UNCOV
560
    exported_target: ExportedTarget
×
UNCOV
561
    sources: DistBuildSources
×
UNCOV
562
    interpreter_constraints: InterpreterConstraints
×
563

564

UNCOV
565
@dataclass(frozen=True)
×
UNCOV
566
class GeneratedSetupPy:
×
UNCOV
567
    digest: Digest
×
568

569

UNCOV
570
@rule(desc="Get exporting owner for target")
×
UNCOV
571
async def get_exporting_owner(owned_dependency: OwnedDependency) -> ExportedTarget:
×
572
    """Find the exported target that owns the given target (and therefore exports it).
573

574
    The owner of T (i.e., the exported target in whose artifact T's code is published) is:
575

576
     1. An exported target that depends on T (or is T itself).
577
     2. Is T's closest filesystem ancestor among those satisfying 1.
578

579
    If there are multiple such exported targets at the same degree of ancestry, the ownership
580
    is ambiguous and an error is raised. If there is no exported target that depends on T
581
    and is its ancestor, then there is no owner and an error is raised.
582
    """
583
    target = owned_dependency.target
×
584
    ancestor_addrs = AncestorGlobSpec(target.address.spec_path)
×
585
    ancestor_tgts = await resolve_targets(
×
586
        **implicitly(
587
            RawSpecs(
588
                ancestor_globs=(ancestor_addrs,),
589
                description_of_origin="the `python_distribution` `package` rules",
590
            )
591
        )
592
    )
593
    # Note that addresses sort by (spec_path, target_name), and all these targets are
594
    # ancestors of the given target, i.e., their spec_paths are all prefixes. So sorting by
595
    # address will effectively sort by closeness of ancestry to the given target.
596
    exported_ancestor_tgts = sorted(
×
597
        (t for t in ancestor_tgts if t.has_field(PythonProvidesField)),
598
        key=lambda t: t.address,
599
        reverse=True,
600
    )
601
    exported_ancestor_iter = iter(exported_ancestor_tgts)
×
602
    for exported_ancestor in exported_ancestor_iter:
×
603
        transitive_targets = await transitive_targets_get(
×
604
            TransitiveTargetsRequest([exported_ancestor.address]), **implicitly()
605
        )
606
        if target in transitive_targets.closure:
×
607
            owner = exported_ancestor
×
608
            # Find any exported siblings of owner that also depend on target. They have the
609
            # same spec_path as it, so they must immediately follow it in ancestor_iter.
610
            sibling_owners = []
×
611
            sibling = next(exported_ancestor_iter, None)
×
612
            while sibling and sibling.address.spec_path == owner.address.spec_path:
×
613
                transitive_targets = await transitive_targets_get(
×
614
                    TransitiveTargetsRequest([sibling.address]), **implicitly()
615
                )
616
                if target in transitive_targets.closure:
×
617
                    sibling_owners.append(sibling)
×
618
                sibling = next(exported_ancestor_iter, None)
×
619
            if sibling_owners:
×
620
                all_owners = [exported_ancestor] + sibling_owners
×
621
                raise AmbiguousOwnerError(
×
622
                    softwrap(
623
                        f"""
624
                        Found multiple sibling python_distribution targets that are the closest
625
                        ancestor dependents of {target.address} and are therefore candidates to
626
                        own it: {", ".join(o.address.spec for o in all_owners)}. Only a
627
                        single such owner is allowed, to avoid ambiguity.
628
                        """
629
                    )
630
                )
631
            return ExportedTarget(owner)
×
632
    raise NoOwnerError(
×
633
        softwrap(
634
            f"""
635
            No python_distribution target found to own {target.address}. Note that
636
            the owner must be in or above the owned target's directory, and must
637
            depend on it (directly or indirectly).
638
            """
639
        )
640
    )
641

642

UNCOV
643
@rule(desc="Find all code to be published in the distribution", level=LogLevel.DEBUG)
×
UNCOV
644
async def get_owned_dependencies(
×
645
    dependency_owner: DependencyOwner, union_membership: UnionMembership
646
) -> OwnedDependencies:
647
    """Find the dependencies of dependency_owner that are owned by it.
648

649
    Includes dependency_owner itself.
650
    """
651
    transitive_targets = await transitive_targets_get(
×
652
        TransitiveTargetsRequest([dependency_owner.exported_target.target.address]), **implicitly()
653
    )
654
    ownable_targets = [
×
655
        tgt for tgt in transitive_targets.closure if is_ownable_target(tgt, union_membership)
656
    ]
657
    owners = await concurrently(
×
658
        get_exporting_owner(OwnedDependency(tgt)) for tgt in ownable_targets
659
    )
660
    owned_dependencies = [
×
661
        tgt
662
        for owner, tgt in zip(owners, ownable_targets)
663
        if owner == dependency_owner.exported_target
664
    ]
665
    return OwnedDependencies(OwnedDependency(t) for t in owned_dependencies)
×
666

667

UNCOV
668
@rule
×
UNCOV
669
async def get_sources(
×
670
    request: DistBuildChrootRequest, union_membership: UnionMembership
671
) -> DistBuildSources:
672
    owned_deps, transitive_targets = await concurrently(
×
673
        get_owned_dependencies(DependencyOwner(request.exported_target), **implicitly()),
674
        transitive_targets_get(
675
            TransitiveTargetsRequest([request.exported_target.target.address]), **implicitly()
676
        ),
677
    )
678
    # files() targets aren't owned by a single exported target - they aren't code, so
679
    # we allow them to be in multiple dists. This is helpful for, e.g., embedding
680
    # a standard license file in a dist.
681
    # TODO: This doesn't actually work, the generated setup.py has no way of referencing
682
    #  these, since they aren't in a package, so they won't get included in the built dists.
683
    # There is a separate `license_files()` setup.py kwarg that we should use for this
684
    # special case (see https://setuptools.pypa.io/en/latest/references/keywords.html).
685
    file_targets = targets_with_sources_types(
×
686
        [FileSourceField], transitive_targets.closure, union_membership
687
    )
688
    targets = Targets(sorted(itertools.chain((od.target for od in owned_deps), file_targets)))
×
689

690
    python_sources_request = PythonSourceFilesRequest(
×
691
        targets=targets, include_resources=False, include_files=False
692
    )
693
    all_sources_request = PythonSourceFilesRequest(
×
694
        targets=targets, include_resources=True, include_files=True
695
    )
696
    python_sources, all_sources = await concurrently(
×
697
        strip_python_sources(**implicitly({python_sources_request: PythonSourceFilesRequest})),
698
        strip_python_sources(**implicitly({all_sources_request: PythonSourceFilesRequest})),
699
    )
700

701
    python_files = set(python_sources.stripped_source_files.snapshot.files)
×
702
    all_files = set(all_sources.stripped_source_files.snapshot.files)
×
703
    resource_files = all_files - python_files
×
704

705
    init_py_digest_contents = await get_digest_contents(
×
706
        **implicitly(
707
            DigestSubset(
708
                python_sources.stripped_source_files.snapshot.digest, PathGlobs(["**/__init__.py"])
709
            )
710
        )
711
    )
712

713
    packages, namespace_packages, package_data = find_packages(
×
714
        python_files=python_files,
715
        resource_files=resource_files,
716
        init_py_digest_contents=init_py_digest_contents,
717
        # Whether to use py2 or py3 package semantics.
718
        py2=request.interpreter_constraints.includes_python2(),
719
    )
720
    return DistBuildSources(
×
721
        digest=all_sources.stripped_source_files.snapshot.digest,
722
        packages=packages,
723
        namespace_packages=namespace_packages,
724
        package_data=package_data,
725
    )
726

727

UNCOV
728
@rule(desc="Compute distribution's 3rd party requirements")
×
UNCOV
729
async def get_requirements(
×
730
    dep_owner: DependencyOwner,
731
    union_membership: UnionMembership,
732
    setup_py_generation: SetupPyGeneration,
733
) -> ExportedTargetRequirements:
734
    transitive_targets = await transitive_targets_get(
×
735
        TransitiveTargetsRequest([dep_owner.exported_target.target.address]), **implicitly()
736
    )
737
    ownable_tgts = [
×
738
        tgt for tgt in transitive_targets.closure if is_ownable_target(tgt, union_membership)
739
    ]
740
    owners = await concurrently(get_exporting_owner(OwnedDependency(tgt)) for tgt in ownable_tgts)
×
741
    owned_by_us: set[Target] = set()
×
742
    owned_by_others: set[Target] = set()
×
743
    for tgt, owner in zip(ownable_tgts, owners):
×
744
        (owned_by_us if owner == dep_owner.exported_target else owned_by_others).add(tgt)
×
745

746
    # Get all 3rdparty deps of our owned deps.
747
    #
748
    # Note that we need only consider requirements that are direct dependencies of our owned deps:
749
    # If T depends on R indirectly, then it must be via some direct deps U1, U2, ... For each such U,
750
    # if U is in the owned deps then we'll pick up R through U. And if U is not in the owned deps
751
    # then it's owned by an exported target ET, and so R will be in the requirements for ET, and we
752
    # will require ET.
753
    direct_deps_tgts = await concurrently(
×
754
        resolve_targets(**implicitly(DependenciesRequest(tgt.get(Dependencies))))
755
        for tgt in owned_by_us
756
    )
757
    direct_deps_chained = OrderedSet(itertools.chain.from_iterable(direct_deps_tgts))
×
758
    # If a python_requirement T has an undeclared requirement R, we recommend fixing that by adding
759
    # an explicit dependency from T to a python_requirement target for R. In that case we want to
760
    # represent these explicit deps in T's distribution metadata. See issue #17593.
761
    transitive_explicit_reqs = await concurrently(
×
762
        transitive_targets_get(TransitiveTargetsRequest([tgt.address]), **implicitly())
763
        for tgt in direct_deps_chained
764
        if tgt.has_field(PythonRequirementsField)
765
    )
766

767
    transitive_excludes: FrozenOrderedSet[Target] = FrozenOrderedSet()
×
768
    uneval_trans_excl = [
×
769
        tgt.get(Dependencies).unevaluated_transitive_excludes for tgt in transitive_targets.closure
770
    ]
771
    if uneval_trans_excl:
×
772
        nested_trans_excl = await concurrently(
×
773
            resolve_targets(**implicitly({unparsed: UnparsedAddressInputs}))
774
            for unparsed in uneval_trans_excl
775
        )
776
        transitive_excludes = FrozenOrderedSet(
×
777
            itertools.chain.from_iterable(excludes for excludes in nested_trans_excl)
778
        )
779

780
    direct_deps_chained.update(
×
781
        itertools.chain.from_iterable(t.dependencies for t in transitive_explicit_reqs)
782
    )
783
    direct_deps_with_excl = direct_deps_chained.difference(transitive_excludes)
×
784

785
    req_strs = list(
×
786
        PexRequirements.req_strings_from_requirement_fields(
787
            (
788
                tgt[PythonRequirementsField]
789
                for tgt in direct_deps_with_excl
790
                if tgt.has_field(PythonRequirementsField)
791
            ),
792
        )
793
    )
794

795
    # Add the requirements on any exported targets on which we depend.
796
    kwargs_for_exported_targets_we_depend_on = await concurrently(
×
797
        determine_explicitly_provided_setup_kwargs(**implicitly(OwnedDependency(tgt)))
798
        for tgt in owned_by_others
799
    )
800
    req_strs.extend(
×
801
        f"{kwargs.name}{setup_py_generation.first_party_dependency_version(kwargs.version)}"
802
        for kwargs in set(kwargs_for_exported_targets_we_depend_on)
803
    )
804
    return ExportedTargetRequirements(req_strs)
×
805

806

UNCOV
807
@rule
×
UNCOV
808
async def determine_finalized_setup_kwargs(request: GenerateSetupPyRequest) -> FinalizedSetupKwargs:
×
809
    exported_target = request.exported_target
×
810
    sources = request.sources
×
811
    requirements = await get_requirements(DependencyOwner(exported_target), **implicitly())
×
812

813
    # Generate the kwargs for the setup() call. In addition to using the kwargs that are either
814
    # explicitly provided or generated via a user's plugin, we add additional kwargs based on the
815
    # resolved requirements and sources.
816
    target = exported_target.target
×
817
    resolved_setup_kwargs = await determine_explicitly_provided_setup_kwargs(
×
818
        exported_target, **implicitly()
819
    )
820
    setup_kwargs = resolved_setup_kwargs.kwargs.copy()
×
821

822
    # Check interpreter constraints
823
    if len(request.interpreter_constraints) > 1:
×
824
        raise SetupPyError(
×
825
            softwrap(
826
                f"""
827
                Expected a single interpreter constraint for {target.address}, got:
828
                {request.interpreter_constraints}.
829

830
                Python distributions do not support multiple constraints, so this will need to be
831
                translated into a single interpreter constraint using exclusions to get the same
832
                effect.
833

834
                As example, given two constraints:
835

836
                    >=2.7,<3 OR >=3.5,<3.11
837

838
                these can be combined into a single constraint using exclusions:
839

840
                    >=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,<3.11
841

842
                """
843
            )
844
        )
845
    if len(request.interpreter_constraints) > 0:
×
846
        # Do not replace value if already set.
847
        setup_kwargs.setdefault(
×
848
            "python_requires",
849
            # Pick the first constraint using a generator detour, as the InterpreterConstraints is
850
            # based on a FrozenOrderedSet which is not indexable.
851
            next(str(ic.specifier) for ic in request.interpreter_constraints),
852
        )
853

854
    # The cascading defaults here are for two levels of "I know what I'm
855
    # doing. Normally the plugin will calculate the appropriate values.  If
856
    # the user Really Knows what they are doing and has gone out of their way
857
    # to use a `SetupKwargs` plugin, and to have also specified
858
    # `SetupKwargs(_allow_banned_keys=True)`, then instead the values are
859
    # merged.  If the user REALLY REALLY knows what they are doing, they can
860
    # use `_overwrite_banned_keys=("the-key",)` in their plugin to use only
861
    # their value for that key.
862
    def _overwrite_value(key: str) -> bool:
×
863
        return key in resolved_setup_kwargs._overwrite_banned_keys
×
864

865
    setup_kwargs.update(
×
866
        {
867
            "packages": (
868
                *(sources.packages if not _overwrite_value("packages") else []),
869
                *(setup_kwargs.get("packages", [])),
870
            ),
871
            "namespace_packages": (
872
                *(sources.namespace_packages if not _overwrite_value("namespace_packages") else []),
873
                *setup_kwargs.get("namespace_packages", []),
874
            ),
875
            "package_data": {
876
                **dict(sources.package_data if not _overwrite_value("package_data") else {}),
877
                **setup_kwargs.get("package_data", {}),
878
            },
879
            "install_requires": (
880
                *(requirements if not _overwrite_value("install_requires") else []),
881
                *setup_kwargs.get("install_requires", []),
882
            ),
883
        }
884
    )
885

886
    long_description_path = exported_target.target.get(LongDescriptionPathField).value
×
887

888
    if "long_description" in setup_kwargs and long_description_path:
×
889
        raise InvalidFieldException(
×
890
            softwrap(
891
                f"""
892
                The {repr(LongDescriptionPathField.alias)} field of the
893
                target {exported_target.target.address} is set, but
894
                'long_description' is already provided explicitly in
895
                the provides=setup_py() field. You may only set one
896
                of these two values.
897
                """
898
            )
899
        )
900

901
    if long_description_path:
×
902
        digest_contents = await get_digest_contents(
×
903
            **implicitly(
904
                PathGlobs(
905
                    [long_description_path],
906
                    description_of_origin=softwrap(
907
                        f"""
908
                    the {LongDescriptionPathField.alias}
909
                    field of {exported_target.target.address}
910
                    """
911
                    ),
912
                    glob_match_error_behavior=GlobMatchErrorBehavior.error,
913
                )
914
            )
915
        )
916
        long_description_content = digest_contents[0].content.decode()
×
917
        setup_kwargs.update({"long_description": long_description_content})
×
918

919
    # Resolve entry points from python_distribution(entry_points=...) and from
920
    # python_distribution(provides=setup_py(entry_points=...)
921
    # TODO: Circular reference for call-by-name
922
    resolved_from_entry_points_field, resolved_from_provides_field = await concurrently(
×
923
        resolve_python_distribution_entry_points(
924
            ResolvePythonDistributionEntryPointsRequest(
925
                entry_points_field=exported_target.target.get(PythonDistributionEntryPointsField)
926
            )
927
        ),
928
        resolve_python_distribution_entry_points(
929
            ResolvePythonDistributionEntryPointsRequest(
930
                provides_field=exported_target.target.get(PythonProvidesField)
931
            )
932
        ),
933
    )
934

935
    def _format_entry_points(
×
936
        resolved: ResolvedPythonDistributionEntryPoints,
937
    ) -> dict[str, dict[str, str]]:
938
        return {
×
939
            category: {ep_name: ep_val.entry_point.spec for ep_name, ep_val in entry_points.items()}
940
            for category, entry_points in resolved.val.items()
941
        }
942

943
    # Gather entry points with source description for any error messages when merging them.
944
    exported_addr = exported_target.target.address
×
945
    entry_point_sources = {
×
946
        f"{exported_addr}'s field `entry_points`": _format_entry_points(
947
            resolved_from_entry_points_field
948
        ),
949
        f"{exported_addr}'s field `provides=setup_py()`": _format_entry_points(
950
            resolved_from_provides_field
951
        ),
952
    }
953

954
    # Merge all collected entry points and add them to the dist's entry points.
955
    all_entry_points = merge_entry_points(*list(entry_point_sources.items()))
×
956
    if all_entry_points:
×
957
        setup_kwargs["entry_points"] = {
×
958
            category: [f"{name} = {entry_point}" for name, entry_point in entry_points.items()]
959
            for category, entry_points in all_entry_points.items()
960
        }
961

962
    return FinalizedSetupKwargs(setup_kwargs, address=target.address)
×
963

964

UNCOV
965
@rule
×
UNCOV
966
async def generate_setup_py(request: GenerateSetupPyRequest) -> GeneratedSetupPy:
×
967
    # Generate the setup script.
968
    finalized_setup_kwargs = await determine_finalized_setup_kwargs(request)
×
969
    setup_py_content = SETUP_BOILERPLATE.format(
×
970
        target_address_spec=request.exported_target.target.address.spec,
971
        setup_kwargs_str=distutils_repr(finalized_setup_kwargs.kwargs),
972
    ).encode()
973
    files_to_create = [
×
974
        FileContent("setup.py", setup_py_content),
975
        FileContent("MANIFEST.in", b"include *.py"),
976
    ]
977
    digest = await create_digest(CreateDigest(files_to_create))
×
978
    return GeneratedSetupPy(digest)
×
979

980

UNCOV
981
@rule
×
UNCOV
982
async def generate_chroot(
×
983
    request: DistBuildChrootRequest, subsys: SetupPyGeneration
984
) -> DistBuildChroot:
985
    generate_setup = request.exported_target.target.get(GenerateSetupField).value
×
986
    if generate_setup is None:
×
987
        generate_setup = subsys.generate_setup_default
×
988

989
    if generate_setup:
×
990
        sources = await get_sources(request, **implicitly())
×
991
        generated_setup_py = await generate_setup_py(
×
992
            GenerateSetupPyRequest(
993
                request.exported_target, sources, request.interpreter_constraints
994
            )
995
        )
996
        # We currently generate a setup.py that expects to be in the source root.
997
        # TODO: It might make sense to generate one in the target's directory, for
998
        #  consistency with the existing setup.py case.
999
        working_directory = ""
×
1000
        chroot_digest = await merge_digests(
×
1001
            MergeDigests((sources.digest, generated_setup_py.digest))
1002
        )
1003
    else:
1004
        transitive_targets = await transitive_targets_get(
×
1005
            TransitiveTargetsRequest([request.exported_target.target.address]), **implicitly()
1006
        )
1007
        source_files = await prepare_python_sources(
×
1008
            PythonSourceFilesRequest(
1009
                targets=transitive_targets.closure, include_resources=True, include_files=True
1010
            ),
1011
            **implicitly(),
1012
        )
1013
        chroot_digest = source_files.source_files.snapshot.digest
×
1014
        working_directory = request.exported_target.target.address.spec_path
×
1015
    return DistBuildChroot(chroot_digest, working_directory)
×
1016

1017

UNCOV
1018
def is_ownable_target(tgt: Target, union_membership: UnionMembership) -> bool:
×
1019
    return (
×
1020
        # Note that we check for a PythonProvides field so that a python_distribution
1021
        # target can be owned (by itself). This is so that if there are any 3rdparty
1022
        # requirements directly on the python_distribution target, we apply them to the dist.
1023
        # This isn't particularly useful (3rdparty requirements should be on the python_sources
1024
        # that consumes them)... but users may expect it to work anyway.
1025
        tgt.has_field(PythonProvidesField)
1026
        or tgt.has_field(PythonSourceField)
1027
        or tgt.has_field(ResourceSourceField)
1028
        or tgt.get(SourcesField).can_generate(PythonSourceField, union_membership)
1029
        or tgt.get(SourcesField).can_generate(ResourceSourceField, union_membership)
1030
        # We also check for generating sources so that dependencies on `python_sources(sources=[])`
1031
        # is included. Those won't generate any `python_source` targets, but still can be
1032
        # depended upon.
1033
        or tgt.has_field(PythonGeneratingSourcesBase)
1034
    )
1035

1036

1037
# Convenient type alias for the pair (package name, data files in the package).
UNCOV
1038
PackageDatum = tuple[str, tuple[str, ...]]
×
1039

1040

UNCOV
1041
def find_packages(
×
1042
    *,
1043
    python_files: set[str],
1044
    resource_files: set[str],
1045
    init_py_digest_contents: DigestContents,
1046
    py2: bool,
1047
) -> tuple[tuple[str, ...], tuple[str, ...], tuple[PackageDatum, ...]]:
1048
    """Analyze the package structure for the given sources.
1049

1050
    Returns a tuple (packages, namespace_packages, package_data), suitable for use as setup()
1051
    kwargs.
1052
    """
1053
    # Find all packages implied by all the sources.
1054
    packages: set[str] = set()
×
1055
    for file_path in itertools.chain(python_files, resource_files):
×
1056
        # Python 2: An __init__.py file denotes a package.
1057
        # Python 3: Any directory containing python source files is a package.
1058
        if (file_path.endswith(".py") and not py2) or os.path.basename(file_path) == "__init__.py":
×
1059
            packages.add(os.path.dirname(file_path).replace(os.path.sep, "."))
×
1060

1061
    # Now find all package_data.
1062
    package_data: DefaultDict[str, list[str]] = defaultdict(list)
×
1063

1064
    def maybe_add_resource(fp: str) -> None:
×
1065
        # Find the closest enclosing package, if any. Resources will be loaded relative to that.
1066
        maybe_package: str = os.path.dirname(fp).replace(os.path.sep, ".")
×
1067
        while maybe_package and maybe_package not in packages:
×
1068
            maybe_package = maybe_package.rpartition(".")[0]
×
1069
        # If resource is not in a package, ignore it. There's no principled way to load it anyway.
1070
        if not maybe_package:
×
1071
            return
×
1072
        package_data[maybe_package].append(
×
1073
            os.path.relpath(fp, maybe_package.replace(".", os.path.sep))
1074
        )
1075

1076
    for resource_file in resource_files:
×
1077
        maybe_add_resource(resource_file)
×
1078
    for py_file in python_files:
×
1079
        if py_file.endswith(".pyi"):
×
1080
            maybe_add_resource(py_file)
×
1081

1082
    # See which packages are pkg_resources-style namespace packages.
1083
    # Note that implicit PEP 420 namespace packages and pkgutil-style namespace packages
1084
    # should *not* be listed in the setup namespace_packages kwarg. That's for pkg_resources-style
1085
    # namespace packages only. See https://github.com/pypa/sample-namespace-packages/.
1086
    namespace_packages: set[str] = set()
×
1087
    init_py_by_path: dict[str, bytes] = {ipc.path: ipc.content for ipc in init_py_digest_contents}
×
1088
    for pkg in packages:
×
1089
        path = os.path.join(pkg.replace(".", os.path.sep), "__init__.py")
×
1090
        if path in init_py_by_path and declares_pkg_resources_namespace_package(
×
1091
            init_py_by_path[path].decode()
1092
        ):
1093
            namespace_packages.add(pkg)
×
1094

1095
    return (
×
1096
        tuple(sorted(packages)),
1097
        tuple(sorted(namespace_packages)),
1098
        tuple((pkg, tuple(sorted(files))) for pkg, files in sorted(package_data.items())),
1099
    )
1100

1101

UNCOV
1102
def declares_pkg_resources_namespace_package(python_src: str) -> bool:
×
1103
    """Given .py file contents, determine if it declares a pkg_resources-style namespace package.
1104

1105
    Detects pkg_resources-style namespaces. See here for details:
1106
    https://packaging.python.org/guides/packaging-namespace-packages/.
1107

1108
    Note: Accepted namespace package decls are valid Python syntax in all Python versions,
1109
    so this code can, e.g., detect namespace packages in Python 2 code while running on Python 3.
1110
    """
UNCOV
1111
    import ast
×
1112

UNCOV
1113
    def is_name(node: ast.AST, name: str) -> bool:
×
UNCOV
1114
        return isinstance(node, ast.Name) and node.id == name
×
1115

UNCOV
1116
    def is_call_to(node: ast.AST, func_name: str) -> bool:
×
UNCOV
1117
        if not isinstance(node, ast.Call):
×
UNCOV
1118
            return False
×
UNCOV
1119
        func = node.func
×
UNCOV
1120
        return (isinstance(func, ast.Attribute) and func.attr == func_name) or is_name(
×
1121
            func, func_name
1122
        )
1123

UNCOV
1124
    def has_args(call_node: ast.Call, required_arg_ids: tuple[str, ...]) -> bool:
×
UNCOV
1125
        args = call_node.args
×
UNCOV
1126
        if len(args) != len(required_arg_ids):
×
1127
            return False
×
UNCOV
1128
        actual_arg_ids = tuple(arg.id for arg in args if isinstance(arg, ast.Name))
×
UNCOV
1129
        return actual_arg_ids == required_arg_ids
×
1130

UNCOV
1131
    try:
×
UNCOV
1132
        python_src_ast = ast.parse(python_src)
×
UNCOV
1133
    except SyntaxError:
×
1134
        # The namespace package incantations we check for are valid code in all Python versions.
1135
        # So if the code isn't parseable we know it isn't a valid namespace package.
UNCOV
1136
        return False
×
1137

1138
    # Note that these checks are slightly heuristic. It is possible to construct adversarial code
1139
    # that would defeat them. But the only consequence would be an incorrect namespace_packages list
1140
    # in setup.py, and we're assuming our users aren't trying to shoot themselves in the foot.
UNCOV
1141
    for ast_node in ast.walk(python_src_ast):
×
1142
        # pkg_resources-style namespace, e.g.,
1143
        #   __import__('pkg_resources').declare_namespace(__name__).
UNCOV
1144
        if is_call_to(ast_node, "declare_namespace") and has_args(
×
1145
            cast(ast.Call, ast_node), ("__name__",)
1146
        ):
UNCOV
1147
            return True
×
UNCOV
1148
    return False
×
1149

1150

UNCOV
1151
def merge_entry_points(
×
1152
    *all_entry_points_with_descriptions_of_source: tuple[str, dict[str, dict[str, str]]],
1153
) -> dict[str, dict[str, str]]:
1154
    """Merge all entry points, throwing ValueError if there are any conflicts."""
UNCOV
1155
    merged = cast(
×
1156
        # this gives us a two level deep defaultdict with the inner values being of list type
1157
        DefaultDict[str, DefaultDict[str, list[tuple[str, str]]]],
1158
        defaultdict(partial(defaultdict, list)),
1159
    )
1160

UNCOV
1161
    for description_of_source, source_entry_points in all_entry_points_with_descriptions_of_source:
×
UNCOV
1162
        for category, entry_points in source_entry_points.items():
×
UNCOV
1163
            for ep_name, entry_point in entry_points.items():
×
UNCOV
1164
                merged[category][ep_name].append((description_of_source, entry_point))
×
1165

UNCOV
1166
    def _check_entry_point_single_source(
×
1167
        category: str, name: str, entry_points_with_source: list[tuple[str, str]]
1168
    ) -> tuple[str, str]:
UNCOV
1169
        if len(entry_points_with_source) > 1:
×
UNCOV
1170
            raise ValueError(
×
1171
                softwrap(
1172
                    f"""
1173
                    Multiple entry_points registered for {category} {name} in:
1174
                    {", ".join(ep_source for ep_source, _ in entry_points_with_source)}
1175
                    """
1176
                )
1177
            )
UNCOV
1178
        _, entry_point = entry_points_with_source[0]
×
UNCOV
1179
        return name, entry_point
×
1180

UNCOV
1181
    return {
×
1182
        category: dict(
1183
            _check_entry_point_single_source(category, name, entry_points_with_source)
1184
            for name, entry_points_with_source in merged_entry_points.items()
1185
        )
1186
        for category, merged_entry_points in merged.items()
1187
    }
1188

1189

UNCOV
1190
def rules():
×
UNCOV
1191
    return [
×
1192
        *python_sources_rules(),
1193
        *dists_rules(),
1194
        *collect_rules(),
1195
    ]
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