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

Allow subclassing Fields for custom Field behavior with the Target API #9286

Merged

Conversation

Eric-Arellano
Copy link
Contributor

Problem

One of the requirements for the Target API's extensibility goal is that plugin authors can create new target types that behave almost identically to the pre-existing target type, but that somehow override a field. For example, python3_library may override the Compatibility field to always return CPython>=3.5.

(We don't allow plugins to change the behavior of a pre-existing target. You must create a new target type.)

Plugin authors may subclass a particular Field currently, but then core Pants code will stop working correctly. For example, Core Pants code may filter out targets by the Compatibility field. So, a Python3Compatibility field that subclasses Compatibility would no longer be recognized when calling my_tgt.has_fields([Compatibility]) or my_tgt.get(Compatibility), as Python3Compatibility != Compatibility, even though it is a subclass.

Solution

Modify Target.get() and Target.has_fields() to first check if the exact type is registered on the Target, then fall back to checking if the requested Field is a superclass of any of the registered fields.

Result

A plugin author can now define Python3Library, which is identical to PythonLibrary, except for having Python3Compatibility in lieu of Compatibility. Code that depends on my_tgt.has_fields([Compatibility]) will continue to work with this custom target type.

If a plugin needs the more precise Field, that will work too. my_tgt.has_fields([Python3Compatibility]) would return True for Python3Library, but not PythonLibrary. This is a useful property—we can, for example, filter to get all targets with PythonSources, even if other targets have the field Sources.

f"The target `{self}` does not have a field `{field}`. Before calling "
f"`my_tgt.get({field.__name__})`, call `my_tgt.has_fields([{field.__name__}])` to "
"filter out any irrelevant Targets."
)
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 💯 for good error messages!

Copy link
Member

@stuhood stuhood left a comment

Choose a reason for hiding this comment

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

Thanks!

@Eric-Arellano Eric-Arellano merged commit 839a298 into pantsbuild:master Mar 13, 2020
@Eric-Arellano Eric-Arellano deleted the target-api-override-field branch March 13, 2020 03:45
Eric-Arellano added a commit that referenced this pull request Mar 16, 2020
This fleshes out how we use `AsyncField`s, which are much more complex than `PrimitiveField`s.

## Result

```python
sources = await Get[SourcesResult](SourcesRequest, tgt.get(Sources).request)
print(sources.snapshot.files)
```

This also works:

```python
if tgt.has_fields([PythonLibrarySources]):
  sources1 = await Get[Sources](SourcesRequest, tgt.get(PythonLibrarySources).request)
  sources2 = await Get[Sources](SourcesRequest, tgt.get(Sources).request)
  assert sources1 == sources2
```

`PythonSources` and its subclasses will validate that all resulting files end in `*.py` (new behavior). `PythonLibrarySources` and `PythonTestsSources` will use the previous default globs. `PythonBinarySources` will enforce that `sources` is 0 or 1 files (previous behavior).

## Solution

### Ensuring support for subclassed `AsyncField`s

With the Target API, we allow new targets to subclass `Field`s for custom behavior. For example, `PythonLibrarySources` might use the default globs of `*.py` whereas `PythonTestSources` might use the default globs of `test_*.py`.

To allow these custom subclasses of `Field`s, we added support in #9286 for substituting in the subclass with the original parent class. For example, `my_python_library.get(Sources) == my_python_library.get(PythonSources) == my_python_library.get(PythonLibrarySources)`.

This works great with `PrimitiveField` but is tricky to implement with `AsyncField` due to the engine not supporting subclasses.

Originally, I tried achieving this extensibility through a union, which would allow the engine to have multiple ways to get a common result type like `SourcesResult`. But, this created a problem that there became multiple paths in the rule graph to compute the same product, e.g. `Sources->SourcesResult`, `PythonSources->SourcesResult`, etc.

Instead, each `AsyncField` should define a simple `Request` dataclass that simply wraps the underlying `AsyncField`. This allows us to have only one path from `SourcesRequest -> SourcesResult`, but still give custom behavior in the underlying `SourcesRequest`. Within the hydration rule, the rule will call standardized extension points provided by the underlying field.

**This means that the onus is on the `AsyncField` author to expose certain entry points for customizing the field's behavior.** For example, `Sources` defines the entry points of `default_globs` and `validate_snapshot()`. `Dependencies` might provide entry points like `inject_dependencies()` and `validate_dependencies()` (not necessarily, only possibilities).

While this approach has lots of boilerplate and less extensibility than `PrimitiveField`s, it solves the graph ambiguity and still allows for subclassing an `AsyncField`.

### Fixing `__eq__` for `Field`s

The previous naive implementation resulted in `Field`s only comparing their classvar `alias`, rather than their actual underlying values. This meant that the engine would cache values when it should not have.

This tweaks how we use dataclasses to ensure that the engine works correctly with `AsyncField`s.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants