Skip to content

Commit 9271e18

Browse files
authored
Merge pull request #323 from jasonwbarnett/feat/add-pants-support
Add pants support for pytest
2 parents 79b1e85 + b785e85 commit 9271e18

File tree

20 files changed

+1427
-13
lines changed

20 files changed

+1427
-13
lines changed

.buildkite/Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ RUN gem install rspec
1212
RUN yarn global add jest
1313
RUN pip install pytest
1414
RUN pip install buildkite-test-collector==0.2.0
15+
RUN curl --proto '=https' --tlsv1.2 -fsSL https://static.pantsbuild.org/setup/get-pants.sh | bash -s -- --bin-dir /usr/local/bin
1516

1617
# Install curl, download bktec binary, make it executable, place it, and cleanup
1718
RUN apt-get update && \

README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ Buildkite Test Engine Client (bktec) is an open source tool to orchestrate your
44

55
bktec supports multiple test runners and offers various features to enhance your testing workflow. Below is a comparison of the features supported by each test runner:
66

7-
| Feature | RSpec | Jest | Playwright | Cypress | pytest | Go test |
8-
| -------------------------------------------------- | :---: | :--: | :--------: | :-----: | :----: | :--: |
9-
| Filter test files |||||| |
10-
| Automatically retry failed test |||||| |
11-
| Split slow files by individual test example |||||| |
12-
| Mute tests (ignore test failures) |||||| |
13-
| Skip tests |||||| |
7+
| Feature | RSpec | Jest | Playwright | Cypress | pytest | pants (pytest) | Go test |
8+
| -------------------------------------------------- | :---: | :--: | :---------: | :-----: | :-----: | :------------: | :-----: |
9+
| Filter test files |||||| | |
10+
| Automatically retry failed test |||||| | |
11+
| Split slow files by individual test example |||||| | |
12+
| Mute tests (ignore test failures) |||||| | |
13+
| Skip tests |||||| | |
1414

1515
## Installation
1616
The latest version of bktec can be downloaded from https://github.com/buildkite/test-engine-client/releases
@@ -60,6 +60,7 @@ To configure the test runner for bktec, please refer to the detailed guides prov
6060
- [Playwright](./docs/playwright.md)
6161
- [Cypress](./docs/cypress.md)
6262
- [pytest](./docs/pytest.md)
63+
- [pytest pants](./docs/pytest-pants.md)
6364
- [go test](./docs/gotest.md)
6465
- [RSpec](./docs/rspec.md)
6566

docs/pytest-pants.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Using bktec with pants (Experimental)
2+
3+
> [!WARNING]
4+
> Pants support is currently experimental and has limited feature support. Only the following features are supported:
5+
>
6+
> - Automatically retry failed tests
7+
> - Mute tests (ignore test failures)
8+
>
9+
> The following features are not supported:
10+
>
11+
> - Filter test files
12+
> - Split slow files by individual test example
13+
> - Skip tests
14+
15+
To integrate bktec with pants, you need to [install and configure Buildkite Test Collector for pytest](https://buildkite.com/docs/test-engine/python-collectors#pytest-collector) first. Then set the `BUILDKITE_TEST_ENGINE_TEST_RUNNER` environment variable to `pytest-pants`.
16+
17+
Look at the example configuration files in the [pytest_pants testdata directory](../internal/runner/testdata/pytest_pants) for an example of how to add buildkite-test-collector to the pants resolve used by pytest. Specifically:
18+
19+
- [pants.toml](../internal/runner/testdata/pytest_pants/pants.toml) - pants configuration
20+
- [3rdparty/python/BUILD](../internal/runner/testdata/pytest_pants/3rdparty/python/BUILD) - python_requirement targets
21+
- [3rdparty/python/pytest-requirements.txt](../internal/runner/testdata/pytest_pants/3rdparty/python/pytest-requirements.txt) - Python requirements.txt
22+
23+
In the example in the repository, you would need to generate a lockfile next, i.e.
24+
25+
```sh
26+
pants generate-lockfiles --resolve=pytest
27+
```
28+
29+
Only running `pants test` with `python_test` targets is supported at this time.
30+
31+
```sh
32+
export BUILDKITE_TEST_ENGINE_TEST_RUNNER=pytest-pants
33+
export BUILDKITE_TEST_ENGINE_TEST_CMD="pants --filter-target-type=python_test --changed-since=HEAD~1 test -- --json={{resultPath}} --merge-json"
34+
bktec
35+
```
36+
37+
## Configure test command
38+
39+
While pants support is experimental there is no default command. That means it is required to set `BUILDKITE_TEST_ENGINE_TEST_CMD`.
40+
Below are a few recommendations for specific scenarios:
41+
42+
---
43+
44+
```sh
45+
export BUILDKITE_TEST_ENGINE_TEST_CMD="pants --filter-target-type=python_test test //:: -- --json={{resultPath}} --merge-json""
46+
```
47+
48+
This command is a good option if you want to run all python tests in your repository.
49+
50+
---
51+
52+
```sh
53+
export BUILDKITE_TEST_ENGINE_TEST_CMD="pants --filter-target-type=python_test --changed-since=HEAD~1 test -- --json={{resultPath}} --merge-json"
54+
```
55+
56+
This command is a good option if you want to only run the python tests that were
57+
impacted by any changes made since `HEAD~1`. Checkout [pants Advanced target
58+
selection doc][pants-advanced-target-selection] for more information on
59+
`--changed-since`.
60+
61+
---
62+
63+
In both commands, `{{resultPath}}` is replaced with a unique temporary path created by bktec. `--json` option is a custom pytest option added by Buildkite Test Collector to save the result into a JSON file at given path. You can further customize the test command for your specific use case.
64+
65+
> [!IMPORTANT]
66+
> Make sure to append `-- --json={{resultPath}} --merge-json` in your custom pants test command, as bktec requires these options to read the test results for retries and verification purposes.
67+
68+
## Filter test files
69+
70+
There is not support for filtering test files at this time.
71+
72+
## Automatically retry failed tests
73+
74+
You can configure bktec to automatically retry failed tests using the `BUILDKITE_TEST_ENGINE_RETRY_COUNT` environment variable. When this variable is set to a number greater than `0`, bktec will retry each failed test up to the specified number of times, using either the default test command or the command specified in `BUILDKITE_TEST_ENGINE_TEST_CMD`. Because pants caches test results, only failed tests will be retried.
75+
76+
To enable automatic retry, set the following environment variable:
77+
78+
```sh
79+
export BUILDKITE_TEST_ENGINE_RETRY_COUNT=2
80+
```
81+
82+
[pants-advanced-target-selection]: https://www.pantsbuild.org/stable/docs/using-pants/advanced-target-selection

internal/config/validate.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func (c *Config) validate() error {
6060
c.errs.appendFieldError("BUILDKITE_TEST_ENGINE_SUITE_SLUG", "must not be blank")
6161
}
6262

63-
if c.ResultPath == "" && c.TestRunner != "cypress" && c.TestRunner != "pytest" {
63+
if c.ResultPath == "" && c.TestRunner != "cypress" && c.TestRunner != "pytest" && c.TestRunner != "pytest-pants" {
6464
c.errs.appendFieldError("BUILDKITE_TEST_ENGINE_RESULT_PATH", "must not be blank")
6565
}
6666

internal/runner/detector.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,12 @@ func DetectRunner(cfg config.Config) (TestRunner, error) {
4747
return NewPlaywright(runnerConfig), nil
4848
case "pytest":
4949
return NewPytest(runnerConfig), nil
50+
case "pytest-pants":
51+
return NewPytestPants(runnerConfig), nil
5052
case "gotest":
5153
return NewGoTest(runnerConfig), nil
5254
default:
5355
// Update the error message to include the new runner
54-
return nil, errors.New("runner value is invalid, possible values are 'rspec', 'jest', 'cypress', 'playwright', 'pytest', or 'gotest'")
56+
return nil, errors.New("runner value is invalid, possible values are 'rspec', 'jest', 'cypress', 'playwright', 'pytest', 'pytest-pants', or 'gotest'")
5557
}
5658
}

internal/runner/pytest_pants.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package runner
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"strings"
9+
10+
"github.com/buildkite/test-engine-client/internal/plan"
11+
"github.com/kballard/go-shellquote"
12+
)
13+
14+
type PytestPants struct {
15+
RunnerConfig
16+
}
17+
18+
func (p PytestPants) Name() string {
19+
return "pytest-pants"
20+
}
21+
22+
func NewPytestPants(c RunnerConfig) PytestPants {
23+
fmt.Fprintln(os.Stderr, "Info: Python package 'buildkite-test-collector' is required and will not be verified by bktec. Please ensure it is added to the pants resolve used by pytest. See https://github.com/buildkite/test-engine-client/blob/main/docs/pytest-pants.md for more information.")
24+
25+
if c.TestCommand == "" {
26+
fmt.Fprintln(os.Stderr, "Error: The test command must be set via BUILDKITE_TEST_ENGINE_TEST_CMD.")
27+
os.Exit(1)
28+
}
29+
30+
if c.TestFilePattern != "" || c.TestFileExcludePattern != "" {
31+
fmt.Fprintln(os.Stderr, "Warning: Pants test runner variant does not support discovering test files. Please ensure the test command is set correctly via BUILDKITE_TEST_ENGINE_TEST_CMD and do *not* set either:")
32+
fmt.Fprintf(os.Stderr, " BUILDKITE_TEST_ENGINE_TEST_FILE_PATTERN=%q\n", c.TestFilePattern)
33+
fmt.Fprintf(os.Stderr, " BUILDKITE_TEST_ENGINE_TEST_FILE_EXCLUDE_PATTERN=%q\n", c.TestFileExcludePattern)
34+
}
35+
36+
if c.TestFilePattern == "" {
37+
c.TestFilePattern = "**/{*_test,test_*}.py"
38+
}
39+
40+
if c.RetryTestCommand == "" {
41+
c.RetryTestCommand = c.TestCommand
42+
}
43+
44+
if c.ResultPath == "" {
45+
c.ResultPath = getRandomTempFilename()
46+
}
47+
48+
return PytestPants{
49+
RunnerConfig: c,
50+
}
51+
}
52+
53+
func (p PytestPants) Run(result *RunResult, testCases []plan.TestCase, retry bool) error {
54+
testPaths := make([]string, len(testCases))
55+
for i, tc := range testCases {
56+
testPaths[i] = tc.Path
57+
}
58+
59+
command := p.TestCommand
60+
61+
if retry {
62+
command = p.RetryTestCommand
63+
}
64+
65+
cmdName, cmdArgs, err := p.commandNameAndArgs(command, testPaths)
66+
if err != nil {
67+
return fmt.Errorf("failed to build command: %w", err)
68+
}
69+
70+
cmd := exec.Command(cmdName, cmdArgs...)
71+
72+
err = runAndForwardSignal(cmd)
73+
74+
// Only rescue exit code 1 because it indicates a test failures.
75+
// Ref: https://docs.pytest.org/en/7.1.x/reference/exit-codes.html
76+
if exitError := new(exec.ExitError); errors.As(err, &exitError) && exitError.ExitCode() != 1 {
77+
return err
78+
}
79+
80+
tests, parseErr := ParsePytestCollectorResult(p.ResultPath)
81+
82+
if parseErr != nil {
83+
fmt.Println("Buildkite Test Engine Client: Failed to read json output, failed tests will not be retried.")
84+
return err
85+
}
86+
87+
for _, test := range tests {
88+
89+
result.RecordTestResult(plan.TestCase{
90+
Identifier: test.Id,
91+
Format: plan.TestCaseFormatExample,
92+
Scope: test.Scope,
93+
Name: test.Name,
94+
// pytest can execute individual test using node id, which is a filename, classname (if any), and function, separated by `::`.
95+
// Ref: https://docs.pytest.org/en/6.2.x/usage.html#nodeids
96+
Path: fmt.Sprintf("%s::%s", test.Scope, test.Name),
97+
}, test.Result)
98+
}
99+
100+
return nil
101+
}
102+
103+
func (p PytestPants) GetFiles() ([]string, error) {
104+
return []string{}, nil
105+
}
106+
107+
func (p PytestPants) GetExamples(files []string) ([]plan.TestCase, error) {
108+
return nil, fmt.Errorf("not supported in pytest pants")
109+
}
110+
111+
func (p PytestPants) commandNameAndArgs(cmd string, testCases []string) (string, []string, error) {
112+
if strings.Contains(cmd, "{{testExamples}}") {
113+
return "", []string{}, fmt.Errorf("currently, bktec does not support dynamically injecting {{testExamples}}. Please ensure the test command in BUILDKITE_TEST_ENGINE_TEST_CMD does *not* include {{testExamples}}")
114+
}
115+
116+
// Split command into parts before and after the first --
117+
parts := strings.SplitN(cmd, "--", 2)
118+
if len(parts) != 2 {
119+
return "", []string{}, fmt.Errorf("please ensure the test command in BUILDKITE_TEST_ENGINE_TEST_CMD includes a -- separator")
120+
}
121+
122+
// Check that both required flags are after the --
123+
afterDash := parts[1]
124+
if !strings.Contains(afterDash, "--json={{resultPath}}") {
125+
return "", []string{}, fmt.Errorf("please ensure the test command in BUILDKITE_TEST_ENGINE_TEST_CMD includes --json={{resultPath}} after the -- separator")
126+
}
127+
128+
if !strings.Contains(afterDash, "--merge-json") {
129+
return "", []string{}, fmt.Errorf("please ensure the test command in BUILDKITE_TEST_ENGINE_TEST_CMD includes --merge-json after the -- separator")
130+
}
131+
132+
cmd = strings.Replace(cmd, "{{resultPath}}", p.ResultPath, 1)
133+
134+
args, err := shellquote.Split(cmd)
135+
136+
if err != nil {
137+
return "", []string{}, err
138+
}
139+
140+
return args[0], args[1:], nil
141+
}

0 commit comments

Comments
 (0)