# Copyright 2019 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import annotations

import itertools
import logging
import os
import pickle
from abc import ABC, abstractmethod
from collections import defaultdict
from collections.abc import Mapping
from dataclasses import dataclass
from functools import partial
from pathlib import PurePath
from typing import Any, DefaultDict, cast

from pants.backend.python.macros.python_artifact import PythonArtifact
from pants.backend.python.subsystems.setup import PythonSetup
from pants.backend.python.subsystems.setup_py_generation import SetupPyGeneration
from pants.backend.python.target_types import (
    BuildBackendEnvVarsField,
    GenerateSetupField,
    LongDescriptionPathField,
    PythonDistributionEntryPointsField,
    PythonDistributionOutputPathField,
    PythonGeneratingSourcesBase,
    PythonProvidesField,
    PythonRequirementsField,
    PythonSourceField,
    ResolvedPythonDistributionEntryPoints,
    ResolvePythonDistributionEntryPointsRequest,
    SDistConfigSettingsField,
    SDistField,
    WheelConfigSettingsField,
    WheelField,
)
from pants.backend.python.target_types_rules import (
    SetupPyError,
    resolve_python_distribution_entry_points,
)
from pants.backend.python.util_rules.dists import (
    BuildSystemRequest,
    DistBuildRequest,
    distutils_repr,
    find_build_system,
)
from pants.backend.python.util_rules.dists import rules as dists_rules
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
from pants.backend.python.util_rules.pex import Pex
from pants.backend.python.util_rules.pex_requirements import PexRequirements
from pants.backend.python.util_rules.python_sources import (
    PythonSourceFilesRequest,
    prepare_python_sources,
    strip_python_sources,
)
from pants.backend.python.util_rules.python_sources import rules as python_sources_rules
from pants.base.glob_match_error_behavior import GlobMatchErrorBehavior
from pants.base.specs import AncestorGlobSpec, RawSpecs
from pants.core.target_types import FileSourceField, ResourceSourceField
from pants.core.util_rules.env_vars import environment_vars_subset
from pants.engine.addresses import Address, UnparsedAddressInputs
from pants.engine.collection import Collection, DeduplicatedCollection
from pants.engine.env_vars import EnvironmentVars, EnvironmentVarsRequest
from pants.engine.environment import EnvironmentName
from pants.engine.fs import (
    AddPrefix,
    CreateDigest,
    Digest,
    DigestContents,
    DigestSubset,
    FileContent,
    MergeDigests,
    PathGlobs,
)
from pants.engine.internals.graph import resolve_targets
from pants.engine.internals.graph import transitive_targets as transitive_targets_get
from pants.engine.intrinsics import add_prefix, create_digest, get_digest_contents, merge_digests
from pants.engine.rules import collect_rules, concurrently, implicitly, rule
from pants.engine.target import (
    Dependencies,
    DependenciesRequest,
    InvalidFieldException,
    SourcesField,
    Target,
    Targets,
    TransitiveTargetsRequest,
    targets_with_sources_types,
)
from pants.engine.unions import UnionMembership, union
from pants.source.source_root import (
    SourceRootRequest,
    SourceRootsRequest,
    get_optional_source_root,
    get_source_roots,
)
from pants.util.docutil import doc_url
from pants.util.frozendict import FrozenDict
from pants.util.logging import LogLevel
from pants.util.memo import memoized_property
from pants.util.ordered_set import FrozenOrderedSet, OrderedSet
from pants.util.strutil import softwrap

logger = logging.getLogger(__name__)


class InvalidSetupPyArgs(SetupPyError):
    """Indicates invalid arguments to setup.py."""


class TargetNotExported(SetupPyError):
    """Indicates a target that was expected to be exported is not."""


class OwnershipError(SetupPyError):
    """An error related to target ownership calculation."""

    def __init__(self, msg: str):
        super().__init__(
            softwrap(
                f"""
                {msg} See {doc_url("docs/python/overview/building-distributions")} for
                how python_sources targets are mapped to distributions.
                """
            )
        )


class NoOwnerError(OwnershipError):
    """Indicates an exportable target has no owning exported target."""


class AmbiguousOwnerError(OwnershipError):
    """Indicates an exportable target has more than one owning exported target."""


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

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

    target: Target  # In practice, a PythonDistribution.

    @property
    def provides(self) -> PythonArtifact:
        return self.target[PythonProvidesField].value


@dataclass(frozen=True)
class DependencyOwner:
    """An ExportedTarget in its role as an owner of other targets.

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

    exported_target: ExportedTarget


@dataclass(frozen=True)
class OwnedDependency:
    """A target that is owned by some ExportedTarget.

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

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

    target: Target


class OwnedDependencies(Collection[OwnedDependency]):
    pass


class ExportedTargetRequirements(DeduplicatedCollection[str]):
    """The requirements of an ExportedTarget.

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

    sort_input = True


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

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

    digest: Digest
    packages: tuple[str, ...]
    namespace_packages: tuple[str, ...]
    package_data: tuple[PackageDatum, ...]


@dataclass(frozen=True)
class DistBuildChrootRequest:
    """A request to create a chroot for building a dist in."""

    exported_target: ExportedTarget
    interpreter_constraints: InterpreterConstraints


@dataclass(frozen=True)
class SetupKwargs:
    """The keyword arguments to the `setup()` function in the generated `setup.py`."""

    _pickled_bytes: bytes
    _overwrite_banned_keys: tuple[str, ...]

    def __init__(
        self,
        kwargs: Mapping[str, Any],
        *,
        address: Address,
        _allow_banned_keys: bool = False,
        _overwrite_banned_keys: tuple[str, ...] = (),
    ) -> None:
        super().__init__()
        if "name" not in kwargs:
            raise InvalidSetupPyArgs(
                f"Missing a `name` kwarg in the `provides` field for {address}."
            )
        if "version" not in kwargs:
            raise InvalidSetupPyArgs(
                f"Missing a `version` kwarg in the `provides` field for {address}."
            )

        if not _allow_banned_keys:
            for arg in {
                "data_files",
                "install_requires",
                "namespace_packages",
                "package_data",
                "package_dir",
                "packages",
            }:
                if arg in kwargs:
                    raise ValueError(
                        softwrap(
                            f"""
                            {arg} cannot be set in the `provides` field for {address}, but it was
                            set to {kwargs[arg]}. Pants will dynamically set the value for you.
                            """
                        )
                    )
        object.__setattr__(
            self, "_overwrite_banned_keys", _overwrite_banned_keys if _overwrite_banned_keys else ()
        )
        # We serialize with `pickle` so that is hashable. We don't use `FrozenDict` because it
        # would require that all values are immutable, and we may have lists and dictionaries as
        # values. It's too difficult/clunky to convert those all, then to convert them back out of
        # `FrozenDict`. We don't use JSON because it does not preserve data types like `tuple`.
        object.__setattr__(
            self,
            "_pickled_bytes",
            pickle.dumps(dict(sorted(kwargs.items())), protocol=4),
        )

    @memoized_property
    def kwargs(self) -> dict[str, Any]:
        return cast(dict[str, Any], pickle.loads(self._pickled_bytes))

    @property
    def name(self) -> str:
        return cast(str, self.kwargs["name"])

    @property
    def version(self) -> str:
        return cast(str, self.kwargs["version"])


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

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

    target: Target

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

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


@rule(polymorphic=True)
async def get_setup_kwargs(req: SetupKwargsRequest, env_name: EnvironmentName) -> SetupKwargs:
    raise NotImplementedError()


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

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


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

    digest: Digest
    working_directory: str  # Path to dir within digest.


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


class NoDistTypeSelected(ValueError):
    pass


@union(in_scope_types=[EnvironmentName])
@dataclass(frozen=True)
class DistBuildEnvironmentRequest:
    target_addresses: tuple[Address, ...]
    interpreter_constraints: InterpreterConstraints

    @classmethod
    def is_applicable(cls, tgt: Target) -> bool:
        # Union members should override.
        return False


@dataclass(frozen=True)
class DistBuildEnvironment:
    """Various extra information that might be needed to build a dist."""

    extra_build_time_requirements: tuple[Pex, ...]
    extra_build_time_inputs: Digest


@rule(polymorphic=True)
async def create_dist_build_environment(
    req: DistBuildEnvironmentRequest, env_name: EnvironmentName
) -> DistBuildEnvironment:
    raise NotImplementedError()


async def create_dist_build_request(
    dist_target_address: Address,
    python_setup: PythonSetup,
    union_membership: UnionMembership,
    validate_wheel_sdist: bool = True,
) -> DistBuildRequest:
    """Create a DistBuildRequest for a `python_distribution`.

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

    transitive_targets = await transitive_targets_get(
        TransitiveTargetsRequest([dist_target_address]), **implicitly()
    )
    exported_target = ExportedTarget(transitive_targets.roots[0])

    dist_tgt = exported_target.target
    wheel = dist_tgt.get(WheelField).value
    sdist = dist_tgt.get(SDistField).value
    if validate_wheel_sdist and not wheel and not sdist:
        raise NoDistTypeSelected(
            softwrap(
                f"""
                In order to package {dist_tgt.address.spec} at least one of {WheelField.alias!r} or
                {SDistField.alias!r} must be `True`.
                """
            )
        )

    wheel_config_settings = dist_tgt.get(WheelConfigSettingsField).value or FrozenDict()
    sdist_config_settings = dist_tgt.get(SDistConfigSettingsField).value or FrozenDict()
    backend_env_vars = dist_tgt.get(BuildBackendEnvVarsField).value
    if backend_env_vars:
        extra_build_time_env = await environment_vars_subset(
            EnvironmentVarsRequest(sorted(backend_env_vars)), **implicitly()
        )
    else:
        extra_build_time_env = EnvironmentVars()

    interpreter_constraints = InterpreterConstraints.create_from_targets(
        transitive_targets.closure, python_setup
    ) or InterpreterConstraints(python_setup.interpreter_constraints)
    chroot = await generate_chroot(
        DistBuildChrootRequest(
            exported_target,
            interpreter_constraints=interpreter_constraints,
        ),
        **implicitly(),
    )

    # Find the source roots for the build-time 1stparty deps (e.g., deps of setup.py).
    optional_dist_source_root, source_roots_result = await concurrently(
        get_optional_source_root(SourceRootRequest.for_target(dist_tgt), **implicitly()),
        get_source_roots(
            SourceRootsRequest(
                files=[],
                dirs={
                    PurePath(tgt.address.spec_path)
                    for tgt in transitive_targets.closure
                    if tgt.has_field(PythonSourceField) or tgt.has_field(ResourceSourceField)
                },
            )
        ),
    )

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

    # Get any extra build-time environment (e.g., native extension requirements).
    build_env_requests = []
    build_env_request_types = union_membership.get(DistBuildEnvironmentRequest)
    for build_env_request_type in build_env_request_types:
        if build_env_request_type.is_applicable(dist_tgt):
            build_env_requests.append(
                build_env_request_type(
                    tuple(tt.address for tt in transitive_targets.closure), interpreter_constraints
                )
            )

    build_envs = await concurrently(
        [
            create_dist_build_environment(
                **implicitly({build_env_request: DistBuildEnvironmentRequest})
            )
            for build_env_request in build_env_requests
        ]
    )
    extra_build_time_requirements = tuple(
        itertools.chain.from_iterable(
            build_env.extra_build_time_requirements for build_env in build_envs
        )
    )
    input_digest = await merge_digests(
        MergeDigests(
            [chroot.digest, *(build_env.extra_build_time_inputs for build_env in build_envs)]
        )
    )

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

    return DistBuildRequest(
        build_system=build_system,
        interpreter_constraints=interpreter_constraints,
        build_wheel=wheel,
        build_sdist=sdist,
        input=prefixed_input,
        working_directory=working_directory,
        dist_source_root=dist_source_root,
        build_time_source_roots=source_roots,
        target_address_spec=exported_target.target.address.spec,
        wheel_config_settings=wheel_config_settings,
        sdist_config_settings=sdist_config_settings,
        extra_build_time_requirements=extra_build_time_requirements,
        extra_build_time_env=extra_build_time_env,
        output_path=output_path,
    )


SETUP_BOILERPLATE = """
# DO NOT EDIT THIS FILE -- AUTOGENERATED BY PANTS
# Target: {target_address_spec}

from setuptools import setup

setup(**{setup_kwargs_str})
"""


@rule
async def determine_explicitly_provided_setup_kwargs(
    exported_target: ExportedTarget, union_membership: UnionMembership
) -> SetupKwargs:
    target = exported_target.target
    setup_kwargs_requests = union_membership.get(SetupKwargsRequest)
    applicable_setup_kwargs_requests = tuple(
        request for request in setup_kwargs_requests if request.is_applicable(target)
    )

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

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

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


@dataclass(frozen=True)
class GenerateSetupPyRequest:
    exported_target: ExportedTarget
    sources: DistBuildSources
    interpreter_constraints: InterpreterConstraints


@dataclass(frozen=True)
class GeneratedSetupPy:
    digest: Digest


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

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

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

    If there are multiple such exported targets at the same degree of ancestry, the ownership
    is ambiguous and an error is raised. If there is no exported target that depends on T
    and is its ancestor, then there is no owner and an error is raised.
    """
    target = owned_dependency.target
    ancestor_addrs = AncestorGlobSpec(target.address.spec_path)
    ancestor_tgts = await resolve_targets(
        **implicitly(
            RawSpecs(
                ancestor_globs=(ancestor_addrs,),
                description_of_origin="the `python_distribution` `package` rules",
            )
        )
    )
    # Note that addresses sort by (spec_path, target_name), and all these targets are
    # ancestors of the given target, i.e., their spec_paths are all prefixes. So sorting by
    # address will effectively sort by closeness of ancestry to the given target.
    exported_ancestor_tgts = sorted(
        (t for t in ancestor_tgts if t.has_field(PythonProvidesField)),
        key=lambda t: t.address,
        reverse=True,
    )
    exported_ancestor_iter = iter(exported_ancestor_tgts)
    for exported_ancestor in exported_ancestor_iter:
        transitive_targets = await transitive_targets_get(
            TransitiveTargetsRequest([exported_ancestor.address]), **implicitly()
        )
        if target in transitive_targets.closure:
            owner = exported_ancestor
            # Find any exported siblings of owner that also depend on target. They have the
            # same spec_path as it, so they must immediately follow it in ancestor_iter.
            sibling_owners = []
            sibling = next(exported_ancestor_iter, None)
            while sibling and sibling.address.spec_path == owner.address.spec_path:
                transitive_targets = await transitive_targets_get(
                    TransitiveTargetsRequest([sibling.address]), **implicitly()
                )
                if target in transitive_targets.closure:
                    sibling_owners.append(sibling)
                sibling = next(exported_ancestor_iter, None)
            if sibling_owners:
                all_owners = [exported_ancestor] + sibling_owners
                raise AmbiguousOwnerError(
                    softwrap(
                        f"""
                        Found multiple sibling python_distribution targets that are the closest
                        ancestor dependents of {target.address} and are therefore candidates to
                        own it: {", ".join(o.address.spec for o in all_owners)}. Only a
                        single such owner is allowed, to avoid ambiguity.
                        """
                    )
                )
            return ExportedTarget(owner)
    raise NoOwnerError(
        softwrap(
            f"""
            No python_distribution target found to own {target.address}. Note that
            the owner must be in or above the owned target's directory, and must
            depend on it (directly or indirectly).
            """
        )
    )


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

    Includes dependency_owner itself.
    """
    transitive_targets = await transitive_targets_get(
        TransitiveTargetsRequest([dependency_owner.exported_target.target.address]), **implicitly()
    )
    ownable_targets = [
        tgt for tgt in transitive_targets.closure if is_ownable_target(tgt, union_membership)
    ]
    owners = await concurrently(
        get_exporting_owner(OwnedDependency(tgt)) for tgt in ownable_targets
    )
    owned_dependencies = [
        tgt
        for owner, tgt in zip(owners, ownable_targets)
        if owner == dependency_owner.exported_target
    ]
    return OwnedDependencies(OwnedDependency(t) for t in owned_dependencies)


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

    python_sources_request = PythonSourceFilesRequest(
        targets=targets, include_resources=False, include_files=False
    )
    all_sources_request = PythonSourceFilesRequest(
        targets=targets, include_resources=True, include_files=True
    )
    python_sources, all_sources = await concurrently(
        strip_python_sources(**implicitly({python_sources_request: PythonSourceFilesRequest})),
        strip_python_sources(**implicitly({all_sources_request: PythonSourceFilesRequest})),
    )

    python_files = set(python_sources.stripped_source_files.snapshot.files)
    all_files = set(all_sources.stripped_source_files.snapshot.files)
    resource_files = all_files - python_files

    init_py_digest_contents = await get_digest_contents(
        **implicitly(
            DigestSubset(
                python_sources.stripped_source_files.snapshot.digest, PathGlobs(["**/__init__.py"])
            )
        )
    )

    packages, namespace_packages, package_data = find_packages(
        python_files=python_files,
        resource_files=resource_files,
        init_py_digest_contents=init_py_digest_contents,
        # Whether to use py2 or py3 package semantics.
        py2=request.interpreter_constraints.includes_python2(),
    )
    return DistBuildSources(
        digest=all_sources.stripped_source_files.snapshot.digest,
        packages=packages,
        namespace_packages=namespace_packages,
        package_data=package_data,
    )


@rule(desc="Compute distribution's 3rd party requirements")
async def get_requirements(
    dep_owner: DependencyOwner,
    union_membership: UnionMembership,
    setup_py_generation: SetupPyGeneration,
) -> ExportedTargetRequirements:
    transitive_targets = await transitive_targets_get(
        TransitiveTargetsRequest([dep_owner.exported_target.target.address]), **implicitly()
    )
    ownable_tgts = [
        tgt for tgt in transitive_targets.closure if is_ownable_target(tgt, union_membership)
    ]
    owners = await concurrently(get_exporting_owner(OwnedDependency(tgt)) for tgt in ownable_tgts)
    owned_by_us: set[Target] = set()
    owned_by_others: set[Target] = set()
    for tgt, owner in zip(ownable_tgts, owners):
        (owned_by_us if owner == dep_owner.exported_target else owned_by_others).add(tgt)

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

    transitive_excludes: FrozenOrderedSet[Target] = FrozenOrderedSet()
    uneval_trans_excl = [
        tgt.get(Dependencies).unevaluated_transitive_excludes for tgt in transitive_targets.closure
    ]
    if uneval_trans_excl:
        nested_trans_excl = await concurrently(
            resolve_targets(**implicitly({unparsed: UnparsedAddressInputs}))
            for unparsed in uneval_trans_excl
        )
        transitive_excludes = FrozenOrderedSet(
            itertools.chain.from_iterable(excludes for excludes in nested_trans_excl)
        )

    direct_deps_chained.update(
        itertools.chain.from_iterable(t.dependencies for t in transitive_explicit_reqs)
    )
    direct_deps_with_excl = direct_deps_chained.difference(transitive_excludes)

    req_strs = list(
        PexRequirements.req_strings_from_requirement_fields(
            (
                tgt[PythonRequirementsField]
                for tgt in direct_deps_with_excl
                if tgt.has_field(PythonRequirementsField)
            ),
        )
    )

    # Add the requirements on any exported targets on which we depend.
    kwargs_for_exported_targets_we_depend_on = await concurrently(
        determine_explicitly_provided_setup_kwargs(**implicitly(OwnedDependency(tgt)))
        for tgt in owned_by_others
    )
    req_strs.extend(
        f"{kwargs.name}{setup_py_generation.first_party_dependency_version(kwargs.version)}"
        for kwargs in set(kwargs_for_exported_targets_we_depend_on)
    )
    return ExportedTargetRequirements(req_strs)


@rule
async def determine_finalized_setup_kwargs(request: GenerateSetupPyRequest) -> FinalizedSetupKwargs:
    exported_target = request.exported_target
    sources = request.sources
    requirements = await get_requirements(DependencyOwner(exported_target), **implicitly())

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

    # Check interpreter constraints
    if len(request.interpreter_constraints) > 1:
        raise SetupPyError(
            softwrap(
                f"""
                Expected a single interpreter constraint for {target.address}, got:
                {request.interpreter_constraints}.

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

                As example, given two constraints:

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

                these can be combined into a single constraint using exclusions:

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

                """
            )
        )
    if len(request.interpreter_constraints) > 0:
        # Do not replace value if already set.
        setup_kwargs.setdefault(
            "python_requires",
