Skip to content

Add backend for projects that use openstack/stevedore #18132

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Feb 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build-support/bin/generate_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ def run_pants_help_all() -> dict[str, Any]:
"pants.backend.experimental.openapi",
"pants.backend.experimental.openapi.lint.spectral",
"pants.backend.experimental.python",
"pants.backend.experimental.python.framework.stevedore",
"pants.backend.experimental.python.lint.add_trailing_comma",
"pants.backend.experimental.python.lint.autoflake",
"pants.backend.experimental.python.lint.pyupgrade",
Expand Down
2 changes: 1 addition & 1 deletion pants.toml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ remote = "//:buildgrid_remote"

[tailor]
build_file_header = """\
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
# Copyright 2023 Pants project contributors (see CONTRIBUTORS.md).
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed this was out-of-date when I tried to use tailor to add new BUILD files. That could probably be a separate 1-character PR.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would prefer if the template supported some basic placeholders, like # Copyright {year} Pants project... to avoid this issue next year :P

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

# Licensed under the Apache License, Version 2.0 (see LICENSE).
"""
ignore_paths = [
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright 2023 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

python_sources()
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Copyright 2023 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

"""A python "framework" for apps to dynamically load plugins.

See https://github.com/openstack/stevedore for details.
"""

from pants.backend.python.framework.stevedore import python_target_dependencies
from pants.backend.python.framework.stevedore import rules as stevedore_rules
from pants.backend.python.framework.stevedore.target_types import StevedoreNamespace
from pants.backend.python.target_types_rules import rules as python_target_types_rules
from pants.build_graph.build_file_aliases import BuildFileAliases


def build_file_aliases():
return BuildFileAliases(objects={StevedoreNamespace.alias: StevedoreNamespace})


def rules():
return [
*stevedore_rules.rules(),
*python_target_dependencies.rules(),
*python_target_types_rules(),
]
Empty file.
8 changes: 8 additions & 0 deletions src/python/pants/backend/python/framework/stevedore/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Copyright 2023 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

python_sources()

python_tests(
name="tests",
)
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
# Copyright 2023 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import annotations

from collections import defaultdict
from dataclasses import dataclass
from typing import Mapping

from pants.backend.python.dependency_inference.module_mapper import (
PythonModuleOwners,
PythonModuleOwnersRequest,
)
from pants.backend.python.framework.stevedore.target_types import (
AllStevedoreExtensionTargets,
StevedoreExtensionTargets,
StevedoreNamespace,
StevedoreNamespacesField,
StevedoreNamespacesProviderTargetsRequest,
)
from pants.backend.python.target_types import (
PythonDistribution,
PythonDistributionDependenciesField,
PythonDistributionEntryPointsField,
PythonTestsDependenciesField,
PythonTestsGeneratorTarget,
PythonTestTarget,
ResolvedPythonDistributionEntryPoints,
ResolvePythonDistributionEntryPointsRequest,
)
from pants.engine.addresses import Address, Addresses
from pants.engine.rules import Get, MultiGet, collect_rules, rule
from pants.engine.target import (
AllTargets,
DependenciesRequest,
ExplicitlyProvidedDependencies,
FieldSet,
InferDependenciesRequest,
InferredDependencies,
Target,
)
from pants.engine.unions import UnionRule
from pants.util.frozendict import FrozenDict
from pants.util.logging import LogLevel
from pants.util.ordered_set import OrderedSet
from pants.util.strutil import softwrap

# -----------------------------------------------------------------------------------------------
# Utility rules to analyze all StevedoreNamespace entry_points
# -----------------------------------------------------------------------------------------------


@rule(
desc=f"Find all `{PythonDistribution.alias}` targets with `{StevedoreNamespace.alias}` entry_points",
level=LogLevel.DEBUG,
)
def find_all_python_distributions_with_any_stevedore_entry_points(
targets: AllTargets,
) -> AllStevedoreExtensionTargets:
# This only supports specifying stevedore_namespace entry points in the
# `entry_points` field of a `python_distribution`, not the `provides` field.
# Use this: `python_distribution(entry_points={...})`
# NOT this: `python_distribution(provides=python_artifact(entry_points={...}))`
return AllStevedoreExtensionTargets(
tgt
for tgt in targets
if tgt.has_field(PythonDistributionEntryPointsField)
and any(
# namespace aka category aka group
isinstance(namespace, StevedoreNamespace)
for namespace in (tgt[PythonDistributionEntryPointsField].value or {}).keys()
)
)


@dataclass(frozen=True)
class StevedoreExtensions:
"""A mapping of stevedore namespaces to a list of targets that provide them.

Effectively, the targets are StevedoreExtension targets.
"""

mapping: FrozenDict[StevedoreNamespace, tuple[Target, ...]]


@rule(
desc=f"Create map of `{StevedoreNamespace.alias}` to `{PythonDistribution.alias}` targets",
level=LogLevel.DEBUG,
)
async def map_stevedore_extensions(
stevedore_extensions: AllStevedoreExtensionTargets,
) -> StevedoreExtensions:
mapping: Mapping[StevedoreNamespace, list[Target]] = defaultdict(list)
for tgt in stevedore_extensions:
# namespace aka category aka group
for namespace in (tgt[PythonDistributionEntryPointsField].value or {}).keys():
if isinstance(namespace, StevedoreNamespace):
mapping[namespace].append(tgt)
return StevedoreExtensions(FrozenDict((k, tuple(v)) for k, v in sorted(mapping.items())))


@rule(
desc=f"Find `{PythonDistribution.alias}` targets with entry_points in selected `{StevedoreNamespace.alias}`s",
level=LogLevel.DEBUG,
)
def find_python_distributions_with_entry_points_in_stevedore_namespaces(
request: StevedoreNamespacesProviderTargetsRequest,
stevedore_extensions: StevedoreExtensions,
) -> StevedoreExtensionTargets:
namespaces: StevedoreNamespacesField = request.stevedore_namespaces
if namespaces.value is None:
return StevedoreExtensionTargets(())

return StevedoreExtensionTargets(
{
tgt
for namespace in namespaces.value
for tgt in stevedore_extensions.mapping.get(StevedoreNamespace(namespace), ())
}
)


# -----------------------------------------------------------------------------------------------
# Dependencies for `python_test` and `python_tests` targets
# -----------------------------------------------------------------------------------------------


@dataclass(frozen=True)
class PythonTestsStevedoreNamespaceInferenceFieldSet(FieldSet):
required_fields = (PythonTestsDependenciesField, StevedoreNamespacesField)

stevedore_namespaces: StevedoreNamespacesField


class InferStevedoreNamespacesDependencies(InferDependenciesRequest):
infer_from = PythonTestsStevedoreNamespaceInferenceFieldSet


@rule(
desc=f"Infer dependencies based on `{StevedoreNamespacesField.alias}` field.",
level=LogLevel.DEBUG,
)
async def infer_stevedore_namespaces_dependencies(
request: InferStevedoreNamespacesDependencies,
) -> InferredDependencies:
requested_namespaces: StevedoreNamespacesField = request.field_set.stevedore_namespaces
if requested_namespaces.value is None:
return InferredDependencies(())

targets = await Get(
StevedoreExtensionTargets,
StevedoreNamespacesProviderTargetsRequest(requested_namespaces),
)

# This is based on pants.backend.python.target_type_rules.infer_python_distribution_dependencies,
# but handles multiple targets and filters the entry_points to just get the requested deps.
all_explicit_dependencies = await MultiGet(
Get(
ExplicitlyProvidedDependencies,
DependenciesRequest(tgt[PythonDistributionDependenciesField]),
)
for tgt in targets
)
all_resolved_entry_points = await MultiGet(
Get(
ResolvedPythonDistributionEntryPoints,
ResolvePythonDistributionEntryPointsRequest(tgt[PythonDistributionEntryPointsField]),
)
for tgt in targets
)

all_module_entry_points = [
(tgt.address, namespace, name, entry_point, explicitly_provided_deps)
for tgt, distribution_entry_points, explicitly_provided_deps in zip(
targets, all_resolved_entry_points, all_explicit_dependencies
)
for namespace, entry_points in distribution_entry_points.explicit_modules.items()
for name, entry_point in entry_points.items()
]
all_module_owners = await MultiGet(
Get(PythonModuleOwners, PythonModuleOwnersRequest(entry_point.module, resolve=None))
for _, _, _, entry_point, _ in all_module_entry_points
)
module_owners: OrderedSet[Address] = OrderedSet()
for (address, namespace, name, entry_point, explicitly_provided_deps), owners in zip(
all_module_entry_points, all_module_owners
):
if namespace not in requested_namespaces.value:
continue

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this block have an unowned-entrypoint error somewhere in it? Or am I missing it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I basically copied pants.backend.python.target_type_rules.infer_python_distribution_dependencies and adjusted things to work for multiple targets at once. If there's a missing error, then it should probably be added to infer_python_distribution_dependencies as well.

Does either of Get(ResolvedPythonDistributionEntryPoints, ...) or Get(PythonModuleOwners, ...) handle erroring on unowned entry points?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does either of Get(ResolvedPythonDistributionEntryPoints, ...) or Get(PythonModuleOwners, ...) handle erroring on unowned entry points?

Nope. I don't see anything that errors if an entry point module is unowned.
That applies for:

So, if an unowned entry point is an issue, it is probably an issue for python_distribution and pex_binary as well. It looks to me like unowned imports are handled for python_source, but not for the others: infer_python_dependencies_via_source -> _handle_unowned_imports

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are almost always an issue, but the question is more a level of certainty and user experience: if we cannot be 99% certain that the missing item is a problem, then warning is annoying. And if you can't be 100% certain, then you need a way to silence the error.

In this case, it seems like you could be 100% certain. But I also think that it is fine as a followup.

field_str = repr({namespace: {name: entry_point.spec}})
explicitly_provided_deps.maybe_warn_of_ambiguous_dependency_inference(
owners.ambiguous,
address,
import_reference="module",
context=softwrap(
f"""
The python_distribution target {address} has the field
`entry_points={field_str}`, which maps to the Python module
`{entry_point.module}`
"""
),
)
maybe_disambiguated = explicitly_provided_deps.disambiguated(owners.ambiguous)
unambiguous_owners = owners.unambiguous or (
(maybe_disambiguated,) if maybe_disambiguated else ()
)
module_owners.update(unambiguous_owners)

result: tuple[Address, ...] = Addresses(module_owners)
for distribution_entry_points in all_resolved_entry_points:
result += distribution_entry_points.pex_binary_addresses
return InferredDependencies(result)


def rules():
return [
*collect_rules(),
PythonTestsGeneratorTarget.register_plugin_field(StevedoreNamespacesField),
PythonTestTarget.register_plugin_field(StevedoreNamespacesField),
UnionRule(InferDependenciesRequest, InferStevedoreNamespacesDependencies),
]
Loading