Skip to content

Commit

Permalink
The default sources for conftest.py and *_test.pyi now belong t…
Browse files Browse the repository at this point in the history
…o new target generator `python_test_utils`, not `python_tests` (#13299)

Part 1 of #13238.

## Why we need to fix this

Currently, `python_tests` includes `conftest.py` and `*_test.pyi` in its `sources` field, and it generates `python_test` targets for those. We special case our Pytest code to ignore running tests on those generated targets:

https://github.com/pantsbuild/pants/blob/e8654186c11c87e372bd62ab4f82f39ad2f5b208/src/python/pants/backend/python/subsystems/pytest.py#L50-L57

This will not work with lockfiles #13238: `conftest.py` and `*_test.pyi` both must be modeled by a `python_source` target, not `python_test`. A `python_test` target has the field `resolve: str`, whereas `python_source` has `compatible_resolves: list[str]`.  The `resolve` says which exact resolve to use, and then we validate the transitive closure is compatible with that one. Because a `conftest.py` can be a dependency of many `python_test` targets, it's important that it participates in this `compatible_resolves` mechanism.

## Solution

Add a new `python_test_utils` target generator, which generates `python_source` targets. It behaves identically to the `python_sources`  target generator, except that it has the default `sources` of `conftest.py` and `*_test.pyi`.

Why a new target that behaves identically to `python_sources`? This modeling reduces the risk of folks unintentionally including test support files like `conftest.py` in production, such as in a `pex_binary` and `python_distribution`. For `python_distribution`, it's very common to add a dependency on a `python_sources` target generator, rather than a generated `python_source` target, i.e. `:lib` instead of `foo.py:lib`. We want to avoid the gotcha of `:lib` including `conftest.py` because that's how the default `sources` work, and we don't want users to have to be aware of this gotcha and to do set arithmetic in the `sources` fields of two `python_sources` to make sure there aren't duplicate owners of the same `conftest.py`.

A new target also dramatically minimizes the breaking changes of this PR. Now, the risk is that some files will be unowned, whereas before there was the risk that they'd be owned by a different target generator than before, so might inherit different metadata like `dependencies` and might be included in unexpected places like a `python_distribution` depending on `:lib`.

--

This PR does not deprecate the special casing in `pytest.py` that allows for a `python_test` target to be used with `conftest.py` and `*_test.pyi`. That special casing will be deprecated in a followup.

## Alternatives considered

### `python_tests` generates `python_source` targets

This is technically feasible, but really clunky and confusing to document. For example, the `conftest.py` `python_source` target would presumably default to the global `compatible_resolves` option (configured in `[python-setup]`). To change it, we'd either need to add a field to `python_tests` like `conftest_compatible_resolves`, or the user would have to know to use the `overrides` mechanism from #13270. Not intuitive!

### Deprecating having a default `sources` field for `python_tests` and `python_sources`

Users would have to either explicitly use the new defaults or stick to the old defaults. Once we're confident people aren't using a default, we can safely change it without breaking anyone.

However, this would require a ludicrous amount of churn for a problem that most users don't even have. Even if we automated this change with `./pants update-build-files`, it is still extremely disruptive.

Also, this isn't very feasible because of how we validate that each glob in the `sources` field matches. When it's the field's `default`, we only require _any_ glob to match. When it's explicitly declared in a BUILD file, we require _all_ globs to match. So, a user could not safely set `["*_test.py", "test_*.py", "tests.py"]` in a BUILD file unless they had all those files, which is unlikely.

### A global option to change the default

I don't think this is technically feasible. The Target API is intentionally shielded from the Rules API. There's no way for an option to change the default of a field. It would require a ludicrous amount of special casing in `engine/internals/graph.py` etc to have our Rules code that consumes these fields to change behavior of things like hydrating sources based on the option.


## Impact

Our dependency inference rule for `conftest.py` requires that there be an owning target:

https://github.com/pantsbuild/pants/blob/a9af04dc94de2f718aae5b8ce488faaf8c39c07e/src/python/pants/backend/python/dependency_inference/rules.py#L231-L235

So,, unless you have explicitly put `conftest.py` into a `python_sources` or `python_tests` target's `sources` field, then you'll get this error when upgrading to Pants 2.8:

```
pants.base.exceptions.ResolveError: No owning targets could be found for the file `src/python/pants/conftest.py`.

Please check that there is a BUILD file in the parent directory src/python/pants with a target whose `sources` field includes the file. See https://www.pantsbuild.org/v2.8/docs/targets for more information on target definitions.

You may want to run `./pants tailor` to autogenerate your BUILD files. See https://www.pantsbuild.org/v2.8/docs/create-initial-build-files.
```

It violates our deprecation policy to error here, but we don't think there's a way to gracefully deprecate. Hopefully folks will run `./pants tailor` after encountering this and get `python_test_utils` targets added.

When a new `python_test_utils` target is added, the user may need to update its metadata like its `dependencies` to work properly. That might not be very intuitive, so we should document it in upgrade notes. Fortunately, the risk is mostly that users' tests will fail: there is far less risk this will break production code.

[ci skip-rust]
[ci skip-build-wheels]
  • Loading branch information
Eric-Arellano authored Oct 21, 2021
1 parent 1c24e52 commit 239b695
Show file tree
Hide file tree
Showing 10 changed files with 191 additions and 52 deletions.
15 changes: 8 additions & 7 deletions src/python/pants/BUILD
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
# Copyright 2014 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

# Enable `python -m pants ...` style execution ala `json.tool` or `venv`.
python_sources(
name="entry_point", sources=["__main__.py"], dependencies=["src/python/pants/bin:pants_loader"]
overrides={
# Enable `python -m pants ...` style execution ala `json.tool` or `venv`.
"__main__.py": {"dependencies": ["src/python/pants/bin:pants_loader"]},
"version.py": {"dependencies": ["./VERSION:resources"]},
},
)

python_test_utils(name="test_utils")

python_distribution(
name="pants-packaged",
dependencies=[":entry_point", ":resources"],
dependencies=["./__main__.py", ":resources"],
# Because we have native code, this will cause the wheel to use whatever the ABI is for the
# interpreter used to run setup.py, e.g. `cp36m-macosx_10_15_x86_64`.
sdist=False,
Expand All @@ -25,10 +30,6 @@ python_distribution(
entry_points={"console_scripts": {"pants": "src/python/pants/bin:pants"}},
)

python_sources(name="conftest", sources=["conftest.py"])

python_sources(name="version", sources=["version.py"], dependencies=["./VERSION:resources"])

# NB: we use `dummy.c` to avoid clang/gcc complaining `error: no input files` when building
# `:pants-packaged`. We don't actually need to use any meaningful file here, though, because we
# use `entry_points` to link to the actual native code, so clang/gcc do not need to build any
Expand Down
17 changes: 11 additions & 6 deletions src/python/pants/backend/python/dependency_inference/rules_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
PythonSourceField,
PythonSourcesGeneratorTarget,
PythonTestsGeneratorTarget,
PythonTestUtilsGeneratorTarget,
)
from pants.backend.python.util_rules import ancestor_files
from pants.engine.addresses import Address
Expand Down Expand Up @@ -211,22 +212,24 @@ def test_infer_python_conftests() -> None:
SubsystemRule(PythonInferSubsystem),
QueryRule(InferredDependencies, (InferConftestDependencies,)),
],
target_types=[PythonTestsGeneratorTarget],
target_types=[PythonTestsGeneratorTarget, PythonTestUtilsGeneratorTarget],
)
rule_runner.set_options(
["--backend-packages=pants.backend.python", "--source-root-patterns=src/python"],
["--source-root-patterns=src/python"],
env_inherit={"PATH", "PYENV_ROOT", "HOME"},
)

rule_runner.create_file("src/python/root/conftest.py")
rule_runner.add_to_build_file("src/python/root", "python_tests()")
rule_runner.add_to_build_file("src/python/root", "python_test_utils()")

rule_runner.create_file("src/python/root/mid/conftest.py")
rule_runner.add_to_build_file("src/python/root/mid", "python_tests()")
rule_runner.add_to_build_file("src/python/root/mid", "python_test_utils()")

rule_runner.create_file("src/python/root/mid/leaf/conftest.py")
rule_runner.create_file("src/python/root/mid/leaf/this_is_a_test.py")
rule_runner.add_to_build_file("src/python/root/mid/leaf", "python_tests()")
rule_runner.add_to_build_file(
"src/python/root/mid/leaf", "python_test_utils()\npython_tests(name='tests')"
)

def run_dep_inference(address: Address) -> InferredDependencies:
target = rule_runner.get_target(address)
Expand All @@ -236,7 +239,9 @@ def run_dep_inference(address: Address) -> InferredDependencies:
)

assert run_dep_inference(
Address("src/python/root/mid/leaf", relative_file_path="this_is_a_test.py")
Address(
"src/python/root/mid/leaf", target_name="tests", relative_file_path="this_is_a_test.py"
)
) == InferredDependencies(
[
Address("src/python/root", relative_file_path="conftest.py"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
PythonRequirementTarget,
PythonSourcesGeneratorTarget,
PythonTestsGeneratorTarget,
PythonTestUtilsGeneratorTarget,
)
from pants.backend.python.util_rules import local_dists, pex_from_targets
from pants.core.goals.test import (
Expand Down Expand Up @@ -71,6 +72,7 @@ def rule_runner() -> RuleRunner:
PexBinary,
PythonSourcesGeneratorTarget,
PythonTestsGeneratorTarget,
PythonTestUtilsGeneratorTarget,
PythonRequirementTarget,
PythonDistribution,
],
Expand Down Expand Up @@ -354,7 +356,7 @@ def pytest_runtest_setup(item):
print('In conftest!')
"""
),
f"{SOURCE_ROOT}/BUILD": "python_tests()",
f"{SOURCE_ROOT}/BUILD": "python_test_utils()",
f"{PACKAGE}/tests.py": GOOD_TEST,
f"{PACKAGE}/BUILD": "python_tests()",
}
Expand Down
39 changes: 31 additions & 8 deletions src/python/pants/backend/python/goals/tailor.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
PythonSourcesGeneratorTarget,
PythonTestsGeneratingSourcesField,
PythonTestsGeneratorTarget,
PythonTestUtilsGeneratingSourcesField,
PythonTestUtilsGeneratorTarget,
ResolvedPexEntryPoint,
ResolvePexEntryPointRequest,
)
Expand Down Expand Up @@ -46,12 +48,26 @@ class PutativePythonTargetsRequest(PutativeTargetsRequest):
def classify_source_files(paths: Iterable[str]) -> dict[type[Target], set[str]]:
"""Returns a dict of target type -> files that belong to targets of that type."""
tests_filespec = Filespec(includes=list(PythonTestsGeneratingSourcesField.default))
test_filenames = set(
matches_filespec(tests_filespec, paths=[os.path.basename(path) for path in paths])
test_utils_filespec = Filespec(includes=list(PythonTestUtilsGeneratingSourcesField.default))

path_to_file_name = {path: os.path.basename(path) for path in paths}
test_file_names = set(matches_filespec(tests_filespec, paths=path_to_file_name.values()))
test_util_file_names = set(
matches_filespec(test_utils_filespec, paths=path_to_file_name.values())
)
test_files = {path for path in paths if os.path.basename(path) in test_filenames}
library_files = set(paths) - test_files
return {PythonTestsGeneratorTarget: test_files, PythonSourcesGeneratorTarget: library_files}

test_files = {
path for path, file_name in path_to_file_name.items() if file_name in test_file_names
}
test_util_files = {
path for path, file_name in path_to_file_name.items() if file_name in test_util_file_names
}
library_files = set(paths) - test_files - test_util_files
return {
PythonTestsGeneratorTarget: test_files,
PythonTestUtilsGeneratorTarget: test_util_files,
PythonSourcesGeneratorTarget: library_files,
}


# The order "__main__" == __name__ would also technically work, but is very
Expand All @@ -74,7 +90,7 @@ async def find_putative_targets(
all_owned_sources: AllOwnedSources,
python_setup: PythonSetup,
) -> PutativeTargets:
# Find library/test targets.
# Find library/test/test_util targets.

all_py_files_globs: PathGlobs = req.search_paths.path_globs("*.py")
all_py_files = await Get(Paths, PathGlobs, all_py_files_globs)
Expand All @@ -83,8 +99,15 @@ async def find_putative_targets(
pts = []
for tgt_type, paths in classified_unowned_py_files.items():
for dirname, filenames in group_by_dir(paths).items():
name = "tests" if tgt_type == PythonTestsGeneratorTarget else os.path.basename(dirname)
kwargs = {"name": name} if tgt_type == PythonTestsGeneratorTarget else {}
if issubclass(tgt_type, PythonTestsGeneratorTarget):
name = "tests"
kwargs = {"name": name}
elif issubclass(tgt_type, PythonTestUtilsGeneratorTarget):
name = "test_utils"
kwargs = {"name": name}
else:
name = os.path.basename(dirname)
kwargs = {}
if (
python_setup.tailor_ignore_solitary_init_files
and tgt_type == PythonSourcesGeneratorTarget
Expand Down
23 changes: 19 additions & 4 deletions src/python/pants/backend/python/goals/tailor_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
PexBinary,
PythonSourcesGeneratorTarget,
PythonTestsGeneratorTarget,
PythonTestUtilsGeneratorTarget,
)
from pants.core.goals.tailor import (
AllOwnedSources,
Expand All @@ -31,17 +32,23 @@ def test_classify_source_files() -> None:
"foo/bar/baz_test.py",
"foo/test_bar.py",
"foo/tests.py",
}
source_files = {
"foo/bar/baz.py",
"foo/bar_baz.py",
"foo.pyi",
}
test_util_files = {
"conftest.py",
"foo/bar/baz_test.pyi",
"foo/test_bar.pyi",
"tests.pyi",
}
lib_files = {"foo/bar/baz.py", "foo/bar_baz.py", "foo.pyi"}

assert {
PythonTestsGeneratorTarget: test_files,
PythonSourcesGeneratorTarget: lib_files,
} == classify_source_files(test_files | lib_files)
PythonSourcesGeneratorTarget: source_files,
PythonTestUtilsGeneratorTarget: test_util_files,
} == classify_source_files(test_files | source_files | test_util_files)


@pytest.fixture
Expand Down Expand Up @@ -72,6 +79,7 @@ def test_find_putative_targets(rule_runner: RuleRunner) -> None:
"bar/baz2.py",
"bar/baz2_test.py",
"bar/baz3.py",
"bar/conftest.py",
)
},
}
Expand Down Expand Up @@ -117,6 +125,13 @@ def test_find_putative_targets(rule_runner: RuleRunner) -> None:
["baz1_test.py", "baz2_test.py"],
kwargs={"name": "tests"},
),
PutativeTarget.for_target_type(
PythonTestUtilsGeneratorTarget,
"src/python/foo/bar",
"test_utils",
["conftest.py"],
kwargs={"name": "test_utils"},
),
]
)
== pts
Expand Down
2 changes: 2 additions & 0 deletions src/python/pants/backend/python/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
PythonSourceTarget,
PythonTestsGeneratorTarget,
PythonTestTarget,
PythonTestUtilsGeneratorTarget,
)
from pants.backend.python.util_rules import (
ancestor_files,
Expand Down Expand Up @@ -94,4 +95,5 @@ def target_types():
PythonRequirementsFile,
PythonTestTarget,
PythonTestsGeneratorTarget,
PythonTestUtilsGeneratorTarget,
]
57 changes: 40 additions & 17 deletions src/python/pants/backend/python/target_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,15 +633,7 @@ class PythonTestTarget(Target):


class PythonTestsGeneratingSourcesField(PythonGeneratingSourcesBase):
default = (
"test_*.py",
"*_test.py",
"tests.py",
"conftest.py",
"test_*.pyi",
"*_test.pyi",
"tests.pyi",
)
default = ("test_*.py", "*_test.py", "tests.py")


class PythonTestsOverrideField(OverridesField):
Expand All @@ -668,7 +660,7 @@ class PythonTestsGeneratorTarget(Target):


# -----------------------------------------------------------------------------------------------
# `python_source` and `python_sources` targets
# `python_source`, `python_sources`, and `python_test_utils` targets
# -----------------------------------------------------------------------------------------------


Expand All @@ -683,12 +675,6 @@ class PythonSourceTarget(Target):
help = "A single Python source file."


class PythonSourcesGeneratingSourcesField(PythonGeneratingSourcesBase):
default = ("*.py", "*.pyi") + tuple(
f"!{pat}" for pat in PythonTestsGeneratingSourcesField.default
)


class PythonSourcesOverridesField(OverridesField):
help = generate_file_based_overrides_field_help_message(
PythonSourceTarget.alias,
Expand All @@ -702,16 +688,53 @@ class PythonSourcesOverridesField(OverridesField):
)


class PythonTestUtilsGeneratingSourcesField(PythonGeneratingSourcesBase):
default = ("conftest.py", "test_*.pyi", "*_test.pyi", "tests.pyi")


class PythonSourcesGeneratingSourcesField(PythonGeneratingSourcesBase):
default = (
("*.py", "*.pyi")
+ tuple(f"!{pat}" for pat in PythonTestsGeneratingSourcesField.default)
+ tuple(f"!{pat}" for pat in PythonTestUtilsGeneratingSourcesField.default)
)


class PythonTestUtilsGeneratorTarget(Target):
alias = "python_test_utils"
# Keep in sync with `PythonSourcesGeneratorTarget`, outside of the `sources` field.
core_fields = (
*COMMON_TARGET_FIELDS,
InterpreterConstraintsField,
Dependencies,
PythonTestUtilsGeneratingSourcesField,
PythonSourcesOverridesField,
)
help = (
"Generate a `python_source` target for each file in the `sources` field.\n\n"
"This target generator is intended for test utility files like `conftest.py`, although it "
"behaves identically to the `python_sources` target generator and you can safely use that "
"instead. This target only exists to help you better model and keep separate test support "
"files vs. production files."
)


class PythonSourcesGeneratorTarget(Target):
alias = "python_sources"
# Keep in sync with `PythonTestUtilsGeneratorTarget`, outside of the `sources` field.
core_fields = (
*COMMON_TARGET_FIELDS,
InterpreterConstraintsField,
Dependencies,
PythonSourcesGeneratingSourcesField,
PythonSourcesOverridesField,
)
help = "Generate a `python_source` target for each file in the `sources` field."
help = (
"Generate a `python_source` target for each file in the `sources` field.\n\n"
"You can either use this target generator or `python_test_utils` for test utility files "
"like `conftest.py`. They behave identically, but can help to better model and keep "
"separate test support files vs. production files."
)

deprecated_alias = "python_library"
deprecated_alias_removal_version = "2.9.0.dev0"
Expand Down
Loading

0 comments on commit 239b695

Please sign in to comment.