Skip to content
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

Add __defaults__ BUILD file symbol #15836

Merged
merged 23 commits into from
Jun 23, 2022
Merged
Show file tree
Hide file tree
Changes from 13 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
38 changes: 33 additions & 5 deletions src/python/pants/engine/internals/build_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from __future__ import annotations

import os.path
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any

from pants.build_graph.address import (
Expand All @@ -17,6 +17,7 @@
)
from pants.engine.engine_aware import EngineAwareParameter
from pants.engine.fs import DigestContents, GlobMatchErrorBehavior, PathGlobs, Paths
from pants.engine.internals.defaults import BuildFileDefaults, BuildFileDefaultsProvider
from pants.engine.internals.mapper import AddressFamily, AddressMap
from pants.engine.internals.parser import BuildFilePreludeSymbols, Parser, error_on_imports
from pants.engine.internals.target_adaptor import TargetAdaptor, TargetAdaptorRequest
Expand Down Expand Up @@ -112,22 +113,48 @@ class AddressFamilyDir(EngineAwareParameter):
"""

path: str
build_files_required: bool = field(default=True, hash=False, compare=False)

def debug_hint(self) -> str:
return self.path


@dataclass(frozen=True)
class AddressFamilyRequest:
defaults: BuildFileDefaults
directory: AddressFamilyDir


@rule
async def get_address_family_request(directory: AddressFamilyDir) -> AddressFamilyRequest:
parent = os.path.dirname(directory.path)
if parent != directory.path:
parent_family = await Get(
AddressFamily, AddressFamilyDir(parent, build_files_required=False)
)
defaults = parent_family.defaults
else:
defaults = BuildFileDefaults({})

return AddressFamilyRequest(
defaults=defaults,
directory=directory,
)


@rule(desc="Search for addresses in BUILD files")
async def parse_address_family(
parser: Parser,
build_file_options: BuildFileOptions,
prelude_symbols: BuildFilePreludeSymbols,
directory: AddressFamilyDir,
request: AddressFamilyRequest,
defaults_provider: BuildFileDefaultsProvider,
) -> AddressFamily:
"""Given an AddressMapper and a directory, return an AddressFamily.

The AddressFamily may be empty, but it will not be None.
"""
directory = request.directory
digest_contents = await Get(
DigestContents,
PathGlobs(
Expand All @@ -137,14 +164,15 @@ async def parse_address_family(
)
),
)
if not digest_contents:
if not digest_contents and directory.build_files_required:
raise ResolveError(f"Directory '{directory.path}' does not contain any BUILD files.")

defaults = defaults_provider.get_parser_defaults(directory.path, request.defaults)
address_maps = [
AddressMap.parse(fc.path, fc.content.decode(), parser, prelude_symbols)
AddressMap.parse(fc.path, fc.content.decode(), parser, prelude_symbols, defaults)
for fc in digest_contents
]
return AddressFamily.create(directory.path, address_maps)
return AddressFamily.create(directory.path, address_maps, defaults.freezed_defaults())


@rule
Expand Down
49 changes: 47 additions & 2 deletions src/python/pants/engine/internals/build_files_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,24 @@
from pants.engine.fs import DigestContents, FileContent, PathGlobs
from pants.engine.internals.build_files import (
AddressFamilyDir,
AddressFamilyRequest,
BuildFileOptions,
evaluate_preludes,
parse_address_family,
)
from pants.engine.internals.defaults import BuildFileDefaults, BuildFileDefaultsProvider
from pants.engine.internals.parser import BuildFilePreludeSymbols, Parser
from pants.engine.internals.scheduler import ExecutionError
from pants.engine.internals.target_adaptor import TargetAdaptor, TargetAdaptorRequest
from pants.engine.target import Dependencies, MultipleSourcesField, StringField, Tags, Target
from pants.engine.target import (
Dependencies,
MultipleSourcesField,
RegisteredTargetTypes,
StringField,
Tags,
Target,
)
from pants.engine.unions import UnionMembership
from pants.testutil.rule_runner import (
MockGet,
QueryRule,
Expand All @@ -41,7 +51,11 @@ def test_parse_address_family_empty() -> None:
Parser(build_root="", target_type_aliases=[], object_aliases=BuildFileAliases()),
BuildFileOptions(("BUILD",)),
BuildFilePreludeSymbols(FrozenDict()),
AddressFamilyDir("/dev/null"),
AddressFamilyRequest(
directory=AddressFamilyDir("/dev/null"),
defaults=BuildFileDefaults({}),
),
BuildFileDefaultsProvider(RegisteredTargetTypes({}), UnionMembership({})),
],
mock_gets=[
MockGet(
Expand Down Expand Up @@ -212,6 +226,37 @@ def test_target_adaptor_parsed_correctly(target_adaptor_rule_runner: RuleRunner)
assert target_adaptor.type_alias == "mock_tgt"


def test_target_adaptor_defaults_applied(target_adaptor_rule_runner: RuleRunner) -> None:
target_adaptor_rule_runner.write_files(
{
"helloworld/dir/BUILD": dedent(
"""\
__defaults__(all=dict(tags=["24"]))
mock_tgt(tags=["42"])
mock_tgt(name='t2')
"""
)
}
)
target_adaptor = target_adaptor_rule_runner.request(
TargetAdaptor,
[TargetAdaptorRequest(Address("helloworld/dir"), description_of_origin="tests")],
)
assert target_adaptor.name is None
assert target_adaptor.kwargs["tags"] == ["42"]

target_adaptor = target_adaptor_rule_runner.request(
TargetAdaptor,
[
TargetAdaptorRequest(
Address("helloworld/dir", target_name="t2"), description_of_origin="tests"
)
],
)
assert target_adaptor.name == "t2"
assert target_adaptor.kwargs["tags"] == ["24"]


def test_target_adaptor_not_found(target_adaptor_rule_runner: RuleRunner) -> None:
with pytest.raises(ExecutionError) as exc:
target_adaptor_rule_runner.request(
Expand Down
160 changes: 160 additions & 0 deletions src/python/pants/engine/internals/defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Iterable, Mapping, Tuple, Union

from pants.engine.addresses import Address
from pants.engine.target import ImmutableValue, InvalidFieldException, RegisteredTargetTypes
from pants.engine.unions import UnionMembership
from pants.util.frozendict import FrozenDict

SetDefaultsValueT = Mapping[str, Any]
SetDefaultsKeyT = Union[str, Tuple[str, ...]]
SetDefaultsT = Mapping[SetDefaultsKeyT, SetDefaultsValueT]


@dataclass
class BuildFileDefaultsProvider:
registered_target_types: RegisteredTargetTypes
union_membership: UnionMembership

def get_parser_defaults(
self, path: str, defaults: BuildFileDefaults
) -> BuildFileDefaultsParserState:
return BuildFileDefaultsParserState(
address=Address(path, generated_name="__defaults__"),
defaults=dict(defaults),
provider=self,
)


class BuildFileDefaults(FrozenDict[str, FrozenDict[str, ImmutableValue]]):
"""Map target types to default field values."""


@dataclass
class BuildFileDefaultsParserState:
address: Address
defaults: dict[str, Mapping[str, Any]]
provider: BuildFileDefaultsProvider

@property
def registered_target_types(self) -> RegisteredTargetTypes:
return self.provider.registered_target_types

@property
def union_membership(self) -> UnionMembership:
return self.provider.union_membership

def freezed_defaults(self) -> BuildFileDefaults:
types = self.registered_target_types.aliases_to_types
return BuildFileDefaults(
{
target_alias: FrozenDict(
{
field_type.alias: field_type.compute_value(
raw_value=default, address=self.address
)
for field_alias, default in fields.items()
for field_type in types[target_alias].class_field_types(
self.union_membership
)
if field_alias in (field_type.alias, field_type.deprecated_alias)
}
)
for target_alias, fields in self.defaults.items()
}
)

def get(self, target_alias: str) -> Mapping[str, Any]:
# Used by `pants.engine.internals.parser.Parser._generate_symbols.Registrar.__call__`
return self.defaults.get(target_alias, {})

def set_defaults(
self,
*args: SetDefaultsT,
all: SetDefaultsValueT | None = None,
extend: bool = False,
**kwargs,
) -> None:
defaults: dict[str, dict[str, Any]] = (
{} if not extend else {k: dict(v) for k, v in self.defaults.items()}
)

if all is not None:
self._process_defaults(
defaults,
{tuple(self.registered_target_types.aliases): all},
ignore_unknown_fields=True,
)

for arg in args:
self._process_defaults(defaults, arg)

# Update with new defaults, dropping targets without any default values.
for tgt, default in defaults.items():
if not default:
self.defaults.pop(tgt, None)
else:
self.defaults[tgt] = default

def _process_defaults(
self,
defaults: dict[str, dict[str, Any]],
targets_defaults: SetDefaultsT,
ignore_unknown_fields: bool = False,
):
if not isinstance(targets_defaults, dict):
raise ValueError(
f"Expected dictionary mapping targets to default field values for {self.address} "
f"but got: {type(targets_defaults).__name__}."
)

types = self.registered_target_types.aliases_to_types
for target, default in targets_defaults.items():
if not isinstance(default, dict):
raise ValueError(
f"Invalid default field values in {self.address} for target type {target}, "
f"must be an `dict` but was {default!r} with type `{type(default).__name__}`."
)

targets: Iterable[str]
targets = target if isinstance(target, tuple) else (target,)
for target_alias in map(str, targets):
if target_alias in types:
target_type = types[target_alias]
else:
raise ValueError(f"Unrecognized target type {target_alias} in {self.address}.")

# Copy default dict if we may mutate it.
raw_values = dict(default) if ignore_unknown_fields else default

# Validate that field exists on target
target_fields = target_type.class_field_types(self.union_membership)
valid_field_aliases = set()

# TODO: this valid aliases calculation is done every time a target is instantiated
# as well. But it should be enough to do once, and re-use as it doesn't change
# during a run.
for fld in target_fields:
valid_field_aliases.add(fld.alias)
if fld.deprecated_alias is not None:
valid_field_aliases.add(fld.deprecated_alias)

for field_alias in default.keys():
if field_alias not in valid_field_aliases:
if ignore_unknown_fields:
del raw_values[field_alias]
else:
raise InvalidFieldException(
f"Unrecognized field `{field_alias}` for target {target_type.alias}. "
f"Valid fields are: {', '.join(sorted(valid_field_aliases))}.",
)
# TODO: moved fields for TargetGenerators ? See: `Target._calculate_field_values()`.
# TODO: support parametrization ?
Copy link
Member

Choose a reason for hiding this comment

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

I think that parametrization should "just work", because the parametrize object is preserved, and not expanded until the target is actually constructed.

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 think the issue here would be that we do eager validation of the default values when freezing them.. so we would be passing a Parametrize object to the field.compute_value.

Copy link
Member Author

Choose a reason for hiding this comment

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

Even so, we can avoid this validation, but get stuck on sticking the Parametrize instance in a FrozenDict does not work well:

E           TypeError: Even though you are using a `FrozenDict`, the underlying values are not hashable. Please use hashable (and preferably immutable) types for the underlying values, e.g. use tuples instead of lists and use FrozenOrderedSet instead of set().
E           
E           Original error message: unhashable type: 'dict'
E           
E           Value: FrozenDict({'description': parametrize(a=desc A, b=desc B})


# Merge all provided defaults for this call.
defaults.setdefault(target_type.alias, {}).update(raw_values)
Loading