Skip to content

Commit 95ad08c

Browse files
committed
feat: port pytest_test macro
Comes from caseyduquettesc/rules_python_pytest#13 It's much easier to properly gazelle-generate a BUILD file in this shape rather than the awkward way you're forced to hold our py_pytest_main.
1 parent c355f6b commit 95ad08c

5 files changed

Lines changed: 152 additions & 19 deletions

File tree

examples/pytest/BUILD.bazel

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,15 @@
1-
load("@aspect_rules_py//py:defs.bzl", "py_pytest_main", "py_test")
1+
load("@aspect_rules_py//pytest:defs.bzl", "pytest_test")
22

3-
py_pytest_main(
4-
name = "__test__",
5-
deps = ["@pypi_pytest//:pkg"],
6-
)
7-
8-
py_test(
3+
pytest_test(
94
name = "pytest_test",
105
srcs = [
116
"foo_test.py",
12-
":__test__",
137
],
148
imports = ["../.."],
15-
main = ":__test__.py",
169
package_collisions = "warning",
10+
pip_repo = "pypi",
1711
deps = [
18-
":__test__",
1912
"@pypi_ftfy//:pkg",
2013
"@pypi_neptune//:pkg",
21-
"@pypi_pytest//:pkg",
2214
],
2315
)

py/defs.bzl

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,26 @@ py_unpacked_wheel = _py_unpacked_wheel
2222
resolutions = _resolutions
2323

2424
def _py_binary_or_test(name, rule, srcs, main, deps = [], resolutions = {}, **kwargs):
25+
if type(main) not in ["string", "Label"]:
26+
fail("main must be a Label or a string")
27+
2528
# Compatibility with rules_python, see docs in py_executable.bzl
2629
main_target = "_{}.find_main".format(name)
27-
determine_main(
28-
name = main_target,
29-
target_name = name,
30-
main = main,
31-
srcs = srcs,
32-
**propagate_common_rule_attributes(kwargs)
33-
)
30+
if type(main) == "string":
31+
determine_main(
32+
name = main_target,
33+
target_name = name,
34+
main = main,
35+
srcs = srcs,
36+
**propagate_common_rule_attributes(kwargs)
37+
)
3438

3539
package_collisions = kwargs.pop("package_collisions", None)
3640

3741
rule(
3842
name = name,
3943
srcs = srcs,
40-
main = main_target,
44+
main = main if type(main) == "Label" else main_target,
4145
deps = deps,
4246
resolutions = resolutions,
4347
package_collisions = package_collisions,

pytest/BUILD.bazel

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
exports_files(
2+
["pytest_shim.py"],
3+
visibility = ["//visibility:public"],
4+
)

pytest/defs.bzl

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Use pytest to run tests, using a wrapper script to interface with Bazel.
2+
3+
Example:
4+
5+
```starlark
6+
load("@aspect_rules_py//pytest:defs.bzl", "pytest_test")
7+
8+
pytest_test(
9+
name = "test_w_pytest",
10+
size = "small",
11+
srcs = ["test.py"],
12+
)
13+
```
14+
15+
By default, `@pip//pytest` is added to `deps`.
16+
If sharding is used (when `shard_count > 1`) then `@pip//pytest_shard` is also added.
17+
To instead provide explicit deps for the pytest library, set `pytest_deps`:
18+
19+
```starlark
20+
pytest_test(
21+
name = "test_w_my_pytest",
22+
shard_count = 2,
23+
srcs = ["test.py"],
24+
pytest_deps = [requirement("pytest"), requirement("pytest-shard"), ...],
25+
)
26+
```
27+
"""
28+
29+
load("//py:defs.bzl", "py_test")
30+
31+
def pytest_test(name, srcs, deps = [], args = [], pytest_deps = None, pip_repo = "pip", **kwargs):
32+
"""
33+
Wrapper macro for `py_test` which supports pytest.
34+
35+
Args:
36+
name: A unique name for this target.
37+
srcs: Python source files.
38+
deps: Dependencies, typically `py_library`.
39+
args: Additional command-line arguments to pytest.
40+
See https://docs.pytest.org/en/latest/how-to/usage.html
41+
pytest_deps: Labels of the pytest tool and other packages it may import.
42+
pip_repo: Name of the external repository where Python packages are installed.
43+
It's typically created by `pip.parse`.
44+
This attribute is used only when `pytest_deps` is unset.
45+
**kwargs: Additional named parameters to py_test.
46+
"""
47+
shim_label = Label("//pytest:pytest_shim.py")
48+
49+
if pytest_deps == None:
50+
pytest_deps = ["@{}//pytest".format(pip_repo)]
51+
if kwargs.get("shard_count", 1) > 1:
52+
pytest_deps.append("@{}//pytest_shard".format(pip_repo))
53+
54+
py_test(
55+
name = name,
56+
srcs = [
57+
shim_label,
58+
] + srcs,
59+
main = shim_label,
60+
args = [
61+
"--capture=no",
62+
] + args + ["$(location :%s)" % x for x in srcs],
63+
deps = deps + pytest_deps,
64+
**kwargs
65+
)

pytest/pytest_shim.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""A shim for executing pytest that supports test filtering, sharding, and more."""
2+
3+
import sys
4+
import os
5+
6+
import pytest
7+
8+
9+
if __name__ == "__main__":
10+
pytest_args = ["--ignore=external"]
11+
12+
args = sys.argv[1:]
13+
# pytest < 8.0 runs tests twice if __init__.py is passed explicitly as an argument.
14+
# Remove any __init__.py file to avoid that.
15+
# pytest.version_tuple is available since pytest 7.0
16+
# https://github.com/pytest-dev/pytest/issues/9313
17+
if not hasattr(pytest, "version_tuple") or pytest.version_tuple < (8, 0):
18+
args = [arg for arg in args if arg.startswith("-") or os.path.basename(arg) != "__init__.py"]
19+
20+
if os.environ.get("XML_OUTPUT_FILE"):
21+
pytest_args.append("--junitxml={xml_output_file}".format(xml_output_file=os.environ.get("XML_OUTPUT_FILE")))
22+
23+
# Handle test sharding - requires pytest-shard plugin.
24+
if os.environ.get("TEST_SHARD_INDEX") and os.environ.get("TEST_TOTAL_SHARDS"):
25+
pytest_args.append("--shard-id={shard_id}".format(shard_id=os.environ.get("TEST_SHARD_INDEX")))
26+
pytest_args.append("--num-shards={num_shards}".format(num_shards=os.environ.get("TEST_TOTAL_SHARDS")))
27+
if os.environ.get("TEST_SHARD_STATUS_FILE"):
28+
open(os.environ["TEST_SHARD_STATUS_FILE"], "a").close()
29+
30+
# Handle plugins that generate reports - if they are provided with relative paths (via args),
31+
# re-write it under bazel's test undeclared outputs dir.
32+
if os.environ.get("TEST_UNDECLARED_OUTPUTS_DIR"):
33+
undeclared_output_dir = os.environ.get("TEST_UNDECLARED_OUTPUTS_DIR")
34+
35+
# Flags that take file paths as value.
36+
path_flags = [
37+
"--report-log", # pytest-reportlog
38+
"--json-report-file", # pytest-json-report
39+
"--html", # pytest-html
40+
]
41+
for i, arg in enumerate(args):
42+
for flag in path_flags:
43+
if arg.startswith(f"{flag}="):
44+
arg_split = arg.split("=", 1)
45+
if len(arg_split) == 2 and not os.path.isabs(arg_split[1]):
46+
args[i] = f"{flag}={undeclared_output_dir}/{arg_split[1]}"
47+
48+
if os.environ.get("TESTBRIDGE_TEST_ONLY"):
49+
test_filter = os.environ["TESTBRIDGE_TEST_ONLY"]
50+
51+
# If the test filter does not start with a class-like name, then use test filtering instead
52+
if not test_filter[0].isupper():
53+
# --test_filter=test_module.test_fn or --test_filter=test_module/test_file.py
54+
pytest_args.extend(args)
55+
pytest_args.append("-k={filter}".format(filter=test_filter))
56+
else:
57+
# --test_filter=TestClass.test_fn
58+
for arg in args:
59+
if not arg.startswith("--"):
60+
# arg is a src file. Add test class/method selection to it.
61+
# test.py::TestClass::test_fn
62+
arg = "{arg}::{module_fn}".format(arg=arg, module_fn=test_filter.replace(".", "::"))
63+
pytest_args.append(arg)
64+
else:
65+
pytest_args.extend(args)
66+
67+
print(pytest_args, file=sys.stderr)
68+
raise SystemExit(pytest.main(pytest_args))

0 commit comments

Comments
 (0)