From b785e85b53d8329a414666748498d8eaa3edd49a Mon Sep 17 00:00:00 2001 From: Jason Barnett Date: Fri, 30 May 2025 11:46:20 -0600 Subject: [PATCH] feat: add pants support for pytest --- .buildkite/Dockerfile | 1 + README.md | 15 +- docs/pytest-pants.md | 82 ++ internal/config/validate.go | 2 +- internal/runner/detector.go | 4 +- internal/runner/pytest_pants.go | 141 ++++ internal/runner/pytest_pants_test.go | 272 +++++++ internal/runner/pytest_test.go | 6 +- .../runner/testdata/pytest_pants/.gitignore | 3 + .../pytest_pants/3rdparty/python/BUILD | 8 + .../3rdparty/python/pytest-requirements.txt | 2 + .../pytest_pants/3rdparty/python/pytest.lock | 737 ++++++++++++++++++ internal/runner/testdata/pytest_pants/BUILD | 1 + .../runner/testdata/pytest_pants/README.md | 67 ++ .../testdata/pytest_pants/failing_test.py | 2 + .../runner/testdata/pytest_pants/pants.toml | 20 + .../testdata/pytest_pants/passing_test.py | 2 + .../testdata/pytest_pants/result-failed.json | 1 + main.go | 14 +- main_test.go | 60 ++ 20 files changed, 1427 insertions(+), 13 deletions(-) create mode 100644 docs/pytest-pants.md create mode 100644 internal/runner/pytest_pants.go create mode 100644 internal/runner/pytest_pants_test.go create mode 100644 internal/runner/testdata/pytest_pants/.gitignore create mode 100644 internal/runner/testdata/pytest_pants/3rdparty/python/BUILD create mode 100644 internal/runner/testdata/pytest_pants/3rdparty/python/pytest-requirements.txt create mode 100644 internal/runner/testdata/pytest_pants/3rdparty/python/pytest.lock create mode 100644 internal/runner/testdata/pytest_pants/BUILD create mode 100644 internal/runner/testdata/pytest_pants/README.md create mode 100644 internal/runner/testdata/pytest_pants/failing_test.py create mode 100644 internal/runner/testdata/pytest_pants/pants.toml create mode 100644 internal/runner/testdata/pytest_pants/passing_test.py create mode 100644 internal/runner/testdata/pytest_pants/result-failed.json diff --git a/.buildkite/Dockerfile b/.buildkite/Dockerfile index 054d01d3..970abec3 100644 --- a/.buildkite/Dockerfile +++ b/.buildkite/Dockerfile @@ -12,6 +12,7 @@ RUN gem install rspec RUN yarn global add jest RUN pip install pytest RUN pip install buildkite-test-collector==0.2.0 +RUN curl --proto '=https' --tlsv1.2 -fsSL https://static.pantsbuild.org/setup/get-pants.sh | bash -s -- --bin-dir /usr/local/bin # Install curl, download bktec binary, make it executable, place it, and cleanup RUN apt-get update && \ diff --git a/README.md b/README.md index fe5bd4ce..0bb98410 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ Buildkite Test Engine Client (bktec) is an open source tool to orchestrate your 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: -| Feature | RSpec | Jest | Playwright | Cypress | pytest | Go test | -| -------------------------------------------------- | :---: | :--: | :--------: | :-----: | :----: | :--: | -| Filter test files | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| Automatically retry failed test | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | -| Split slow files by individual test example | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | -| Mute tests (ignore test failures) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | -| Skip tests | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Feature | RSpec | Jest | Playwright | Cypress | pytest | pants (pytest) | Go test | +| -------------------------------------------------- | :---: | :--: | :---------: | :-----: | :-----: | :------------: | :-----: | +| Filter test files | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| Automatically retry failed test | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | +| Split slow files by individual test example | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Mute tests (ignore test failures) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | +| Skip tests | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ## Installation 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 - [Playwright](./docs/playwright.md) - [Cypress](./docs/cypress.md) - [pytest](./docs/pytest.md) +- [pytest pants](./docs/pytest-pants.md) - [go test](./docs/gotest.md) - [RSpec](./docs/rspec.md) diff --git a/docs/pytest-pants.md b/docs/pytest-pants.md new file mode 100644 index 00000000..054f2791 --- /dev/null +++ b/docs/pytest-pants.md @@ -0,0 +1,82 @@ +# Using bktec with pants (Experimental) + +> [!WARNING] +> Pants support is currently experimental and has limited feature support. Only the following features are supported: +> +> - Automatically retry failed tests +> - Mute tests (ignore test failures) +> +> The following features are not supported: +> +> - Filter test files +> - Split slow files by individual test example +> - Skip tests + +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`. + +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: + +- [pants.toml](../internal/runner/testdata/pytest_pants/pants.toml) - pants configuration +- [3rdparty/python/BUILD](../internal/runner/testdata/pytest_pants/3rdparty/python/BUILD) - python_requirement targets +- [3rdparty/python/pytest-requirements.txt](../internal/runner/testdata/pytest_pants/3rdparty/python/pytest-requirements.txt) - Python requirements.txt + +In the example in the repository, you would need to generate a lockfile next, i.e. + +```sh +pants generate-lockfiles --resolve=pytest +``` + +Only running `pants test` with `python_test` targets is supported at this time. + +```sh +export BUILDKITE_TEST_ENGINE_TEST_RUNNER=pytest-pants +export BUILDKITE_TEST_ENGINE_TEST_CMD="pants --filter-target-type=python_test --changed-since=HEAD~1 test -- --json={{resultPath}} --merge-json" +bktec +``` + +## Configure test command + +While pants support is experimental there is no default command. That means it is required to set `BUILDKITE_TEST_ENGINE_TEST_CMD`. +Below are a few recommendations for specific scenarios: + +--- + +```sh +export BUILDKITE_TEST_ENGINE_TEST_CMD="pants --filter-target-type=python_test test //:: -- --json={{resultPath}} --merge-json"" +``` + +This command is a good option if you want to run all python tests in your repository. + +--- + +```sh +export BUILDKITE_TEST_ENGINE_TEST_CMD="pants --filter-target-type=python_test --changed-since=HEAD~1 test -- --json={{resultPath}} --merge-json" +``` + +This command is a good option if you want to only run the python tests that were +impacted by any changes made since `HEAD~1`. Checkout [pants Advanced target +selection doc][pants-advanced-target-selection] for more information on +`--changed-since`. + +--- + +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. + +> [!IMPORTANT] +> 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. + +## Filter test files + +There is not support for filtering test files at this time. + +## Automatically retry failed tests + +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. + +To enable automatic retry, set the following environment variable: + +```sh +export BUILDKITE_TEST_ENGINE_RETRY_COUNT=2 +``` + +[pants-advanced-target-selection]: https://www.pantsbuild.org/stable/docs/using-pants/advanced-target-selection diff --git a/internal/config/validate.go b/internal/config/validate.go index cd718f39..96bbcb58 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -60,7 +60,7 @@ func (c *Config) validate() error { c.errs.appendFieldError("BUILDKITE_TEST_ENGINE_SUITE_SLUG", "must not be blank") } - if c.ResultPath == "" && c.TestRunner != "cypress" && c.TestRunner != "pytest" { + if c.ResultPath == "" && c.TestRunner != "cypress" && c.TestRunner != "pytest" && c.TestRunner != "pytest-pants" { c.errs.appendFieldError("BUILDKITE_TEST_ENGINE_RESULT_PATH", "must not be blank") } diff --git a/internal/runner/detector.go b/internal/runner/detector.go index 58dd11cb..cc4c93a9 100644 --- a/internal/runner/detector.go +++ b/internal/runner/detector.go @@ -47,10 +47,12 @@ func DetectRunner(cfg config.Config) (TestRunner, error) { return NewPlaywright(runnerConfig), nil case "pytest": return NewPytest(runnerConfig), nil + case "pytest-pants": + return NewPytestPants(runnerConfig), nil case "gotest": return NewGoTest(runnerConfig), nil default: // Update the error message to include the new runner - return nil, errors.New("runner value is invalid, possible values are 'rspec', 'jest', 'cypress', 'playwright', 'pytest', or 'gotest'") + return nil, errors.New("runner value is invalid, possible values are 'rspec', 'jest', 'cypress', 'playwright', 'pytest', 'pytest-pants', or 'gotest'") } } diff --git a/internal/runner/pytest_pants.go b/internal/runner/pytest_pants.go new file mode 100644 index 00000000..5e54fd68 --- /dev/null +++ b/internal/runner/pytest_pants.go @@ -0,0 +1,141 @@ +package runner + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/buildkite/test-engine-client/internal/plan" + "github.com/kballard/go-shellquote" +) + +type PytestPants struct { + RunnerConfig +} + +func (p PytestPants) Name() string { + return "pytest-pants" +} + +func NewPytestPants(c RunnerConfig) PytestPants { + 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.") + + if c.TestCommand == "" { + fmt.Fprintln(os.Stderr, "Error: The test command must be set via BUILDKITE_TEST_ENGINE_TEST_CMD.") + os.Exit(1) + } + + if c.TestFilePattern != "" || c.TestFileExcludePattern != "" { + 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:") + fmt.Fprintf(os.Stderr, " BUILDKITE_TEST_ENGINE_TEST_FILE_PATTERN=%q\n", c.TestFilePattern) + fmt.Fprintf(os.Stderr, " BUILDKITE_TEST_ENGINE_TEST_FILE_EXCLUDE_PATTERN=%q\n", c.TestFileExcludePattern) + } + + if c.TestFilePattern == "" { + c.TestFilePattern = "**/{*_test,test_*}.py" + } + + if c.RetryTestCommand == "" { + c.RetryTestCommand = c.TestCommand + } + + if c.ResultPath == "" { + c.ResultPath = getRandomTempFilename() + } + + return PytestPants{ + RunnerConfig: c, + } +} + +func (p PytestPants) Run(result *RunResult, testCases []plan.TestCase, retry bool) error { + testPaths := make([]string, len(testCases)) + for i, tc := range testCases { + testPaths[i] = tc.Path + } + + command := p.TestCommand + + if retry { + command = p.RetryTestCommand + } + + cmdName, cmdArgs, err := p.commandNameAndArgs(command, testPaths) + if err != nil { + return fmt.Errorf("failed to build command: %w", err) + } + + cmd := exec.Command(cmdName, cmdArgs...) + + err = runAndForwardSignal(cmd) + + // Only rescue exit code 1 because it indicates a test failures. + // Ref: https://docs.pytest.org/en/7.1.x/reference/exit-codes.html + if exitError := new(exec.ExitError); errors.As(err, &exitError) && exitError.ExitCode() != 1 { + return err + } + + tests, parseErr := ParsePytestCollectorResult(p.ResultPath) + + if parseErr != nil { + fmt.Println("Buildkite Test Engine Client: Failed to read json output, failed tests will not be retried.") + return err + } + + for _, test := range tests { + + result.RecordTestResult(plan.TestCase{ + Identifier: test.Id, + Format: plan.TestCaseFormatExample, + Scope: test.Scope, + Name: test.Name, + // pytest can execute individual test using node id, which is a filename, classname (if any), and function, separated by `::`. + // Ref: https://docs.pytest.org/en/6.2.x/usage.html#nodeids + Path: fmt.Sprintf("%s::%s", test.Scope, test.Name), + }, test.Result) + } + + return nil +} + +func (p PytestPants) GetFiles() ([]string, error) { + return []string{}, nil +} + +func (p PytestPants) GetExamples(files []string) ([]plan.TestCase, error) { + return nil, fmt.Errorf("not supported in pytest pants") +} + +func (p PytestPants) commandNameAndArgs(cmd string, testCases []string) (string, []string, error) { + if strings.Contains(cmd, "{{testExamples}}") { + 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}}") + } + + // Split command into parts before and after the first -- + parts := strings.SplitN(cmd, "--", 2) + if len(parts) != 2 { + return "", []string{}, fmt.Errorf("please ensure the test command in BUILDKITE_TEST_ENGINE_TEST_CMD includes a -- separator") + } + + // Check that both required flags are after the -- + afterDash := parts[1] + if !strings.Contains(afterDash, "--json={{resultPath}}") { + return "", []string{}, fmt.Errorf("please ensure the test command in BUILDKITE_TEST_ENGINE_TEST_CMD includes --json={{resultPath}} after the -- separator") + } + + if !strings.Contains(afterDash, "--merge-json") { + return "", []string{}, fmt.Errorf("please ensure the test command in BUILDKITE_TEST_ENGINE_TEST_CMD includes --merge-json after the -- separator") + } + + cmd = strings.Replace(cmd, "{{resultPath}}", p.ResultPath, 1) + + args, err := shellquote.Split(cmd) + + if err != nil { + return "", []string{}, err + } + + return args[0], args[1:], nil +} diff --git a/internal/runner/pytest_pants_test.go b/internal/runner/pytest_pants_test.go new file mode 100644 index 00000000..4a8dbd9d --- /dev/null +++ b/internal/runner/pytest_pants_test.go @@ -0,0 +1,272 @@ +package runner + +import ( + "errors" + "os/exec" + "testing" + + "github.com/buildkite/test-engine-client/internal/plan" + "github.com/google/go-cmp/cmp" + "github.com/kballard/go-shellquote" +) + +// test cases are not supported in pytest pants at this time. It's required to +// have all tests cases passed to the test command. +// TODO: add support for test cases in pytest pants. This is a temporary +// workaround to allow bktec to run pytest pants. + +func TestPytestPantsRun(t *testing.T) { + changeCwd(t, "./testdata/pytest_pants") + + pytest := NewPytestPants(RunnerConfig{ + TestCommand: "pants test //passing_test.py -- --json={{resultPath}} --merge-json", + }) + + testCases := []plan.TestCase{ + {Path: "passing_test.py"}, + } + result := NewRunResult([]plan.TestCase{}) + err := pytest.Run(result, testCases, false) + + if err != nil { + t.Errorf("PytestPants.Run(%q) error = %v", testCases, err) + } + + if result.Status() != RunStatusPassed { + t.Errorf("PytestPants.Run(%q) RunResult.Status = %v, want %v", testCases, result.Status(), RunStatusPassed) + } +} + +func TestPytestPantsRun_RetryCommand(t *testing.T) { + changeCwd(t, "./testdata/pytest_pants") + + pytest := NewPytestPants(RunnerConfig{ + TestCommand: "pants test //failing_test.py -- --json={{resultPath}} --merge-json", + RetryTestCommand: "pants test //passing_test.py -- --json={{resultPath}} --merge-json", + }) + + testCases := []plan.TestCase{ + {Path: "passing_test.py"}, + } + + result := NewRunResult([]plan.TestCase{}) + err := pytest.Run(result, testCases, true) + + if err != nil { + t.Errorf("PytestPants.Run(%q) error = %v", testCases, err) + } +} + +func TestPytestPantsRun_TestFailed(t *testing.T) { + changeCwd(t, "./testdata/pytest_pants") + + pytest := NewPytestPants(RunnerConfig{ + TestCommand: "pants test //:: -- --json={{resultPath}} --merge-json", + ResultPath: "result-failed.json", + }) + testCases := []plan.TestCase{ + {Path: "failing_test.py"}, + } + result := NewRunResult([]plan.TestCase{}) + err := pytest.Run(result, testCases, false) + + if err != nil { + t.Errorf("PytestPants.Run(%q) error = %v", testCases, err) + } + + if result.Status() != RunStatusFailed { + t.Errorf("PytestPants.Run(%q) RunResult.Status = %v, want %v", testCases, result.Status(), RunStatusFailed) + } + + failedTest := result.FailedTests() + + if len(failedTest) != 1 { + t.Errorf("len(result.FailedTests()) = %d, want 1", len(failedTest)) + } + + wantFailedTests := []plan.TestCase{ + { + Format: "example", + Identifier: "a1be7e52-0dba-4018-83ce-a1598ca68807", + Name: "test_failed", + Path: "tests/failing_test.py::test_failed", + Scope: "tests/failing_test.py", + }, + } + + if diff := cmp.Diff(failedTest, wantFailedTests); diff != "" { + t.Errorf("PytestPants.Run(%q) RunResult.FailedTests() diff (-got +want):\n%s", testCases, diff) + } +} + +func TestPytestPantsRun_CommandFailed(t *testing.T) { + changeCwd(t, "./testdata/pytest_pants") + + pytest := NewPytestPants(RunnerConfig{ + TestCommand: "pants test //:: -- --non-existent-pytest-option --json={{resultPath}} --merge-json", + }) + + testCases := []plan.TestCase{ + {Path: "passing_test.py"}, + } + result := NewRunResult([]plan.TestCase{}) + err := pytest.Run(result, testCases, false) + + if result.Status() != RunStatusUnknown { + t.Errorf("PytestPants.Run(%q) RunResult.Status = %v, want %v", testCases, result.Status(), RunStatusUnknown) + } + + exitError := new(exec.ExitError) + if !errors.As(err, &exitError) { + t.Errorf("PytestPants.Run(%q) error type = %T (%v), want *exec.ExitError", testCases, err, err) + } +} + +func TestPytestPantsGetFiles(t *testing.T) { + pytest := NewPytestPants(RunnerConfig{ + TestCommand: "pants test //:: -- --json={{resultPath}} --merge-json", + }) + + got, err := pytest.GetFiles() + if err != nil { + t.Errorf("PytestPants.GetFiles() error = %v", err) + } + + // PytestPants doesn't support file discovery + want := []string{} + + if diff := cmp.Diff(got, want); diff != "" { + t.Errorf("PytestPants.GetFiles() diff (-got +want):\n%s", diff) + } +} + +func TestPytestPantsGetExamples(t *testing.T) { + pytest := NewPytestPants(RunnerConfig{ + TestCommand: "pants test //:: -- --json={{resultPath}} --merge-json", + }) + + got, err := pytest.GetExamples([]string{}) + if err == nil { + t.Error("PytestPants.GetExamples() error = nil, want error") + } + + if got != nil { + t.Errorf("PytestPants.GetExamples() = %v, want nil", got) + } +} + +func TestPytestPantsCommandNameAndArgs_WithoutMergeJson(t *testing.T) { + testCases := []string{"failing_test.py", "passing_test.py"} + testCommand := "pants test //:: -- --json={{resultPath}}" + + pytest := NewPytestPants(RunnerConfig{ + TestCommand: testCommand, + ResultPath: "result.json", + }) + + _, _, err := pytest.commandNameAndArgs(testCommand, testCases) + if err == nil { + t.Error("commandNameAndArgs() error = nil, want error") + } +} + +func TestPytestPantsCommandNameAndArgs_WithoutResultPath(t *testing.T) { + testCases := []string{"failing_test.py", "passing_test.py"} + testCommand := "pants test //:: -- --merge-json" + + pytest := NewPytestPants(RunnerConfig{ + TestCommand: testCommand, + }) + + _, _, err := pytest.commandNameAndArgs(testCommand, testCases) + if err == nil { + t.Error("commandNameAndArgs() error = nil, want error") + } +} + +func TestPytestPantsCommandNameAndArgs_PytestArgsBeforeDash(t *testing.T) { + testCases := []string{"failing_test.py", "passing_test.py"} + testCommand := "pants test --json={{resultPath}} --merge-json //::" + + pytest := NewPytestPants(RunnerConfig{ + TestCommand: testCommand, + }) + + _, _, err := pytest.commandNameAndArgs(testCommand, testCases) + if err == nil { + t.Error("commandNameAndArgs() error = nil, want error") + } +} + +func TestPytestPantsCommandNameAndArgs_NoDashSeparator(t *testing.T) { + testCases := []string{"failing_test.py", "passing_test.py"} + testCommand := "pants test //:: --json={{resultPath}} --merge-json" + + pytest := NewPytestPants(RunnerConfig{ + TestCommand: testCommand, + }) + + gotName, gotArgs, err := pytest.commandNameAndArgs(testCommand, testCases) + if err == nil { + t.Error("commandNameAndArgs() error = nil, want error") + } + + wantName := "" + wantArgs := []string{} + + if diff := cmp.Diff(gotName, wantName); diff != "" { + t.Errorf("commandNameAndArgs() diff (-got +want):\n%s", diff) + } + if diff := cmp.Diff(gotArgs, wantArgs); diff != "" { + t.Errorf("commandNameAndArgs() diff (-got +want):\n%s", diff) + } +} + +func TestPytestPantsCommandNameAndArgs_ValidCommand(t *testing.T) { + testCases := []string{"failing_test.py", "passing_test.py"} + testCommand := "pants test //:: -- --json={{resultPath}} --merge-json" + + pytest := NewPytestPants(RunnerConfig{ + TestCommand: testCommand, + ResultPath: "result.json", + }) + + gotName, gotArgs, err := pytest.commandNameAndArgs(testCommand, testCases) + if err != nil { + t.Errorf("commandNameAndArgs(%q, %q) error = %v", testCases, testCommand, err) + } + + wantName := "pants" + wantArgs := []string{"test", "//::", "--", "--json=result.json", "--merge-json"} + + if diff := cmp.Diff(gotName, wantName); diff != "" { + t.Errorf("commandNameAndArgs(%q, %q) diff (-got +want):\n%s", testCases, testCommand, diff) + } + if diff := cmp.Diff(gotArgs, wantArgs); diff != "" { + t.Errorf("commandNameAndArgs(%q, %q) diff (-got +want):\n%s", testCases, testCommand, diff) + } +} + +func TestPytestPantsCommandNameAndArgs_InvalidTestCommand(t *testing.T) { + testCases := []string{"failing_test.py", "passing_test.py"} + testCommand := "pants test //:: -- --json={{resultPath}}' --merge-json" + + pytest := NewPytestPants(RunnerConfig{ + TestCommand: testCommand, + }) + + gotName, gotArgs, err := pytest.commandNameAndArgs(testCommand, testCases) + + wantName := "" + wantArgs := []string{} + + if diff := cmp.Diff(gotName, wantName); diff != "" { + t.Errorf("commandNameAndArgs() diff (-got +want):\n%s", diff) + } + if diff := cmp.Diff(gotArgs, wantArgs); diff != "" { + t.Errorf("commandNameAndArgs() diff (-got +want):\n%s", diff) + } + if !errors.Is(err, shellquote.UnterminatedSingleQuoteError) { + t.Errorf("commandNameAndArgs() error = %v, want %v", err, shellquote.UnterminatedSingleQuoteError) + } +} diff --git a/internal/runner/pytest_test.go b/internal/runner/pytest_test.go index 1ebae4c8..442ac7ec 100644 --- a/internal/runner/pytest_test.go +++ b/internal/runner/pytest_test.go @@ -139,6 +139,8 @@ func TestPytestRun_CommandFailed(t *testing.T) { } func TestPytestGetFiles(t *testing.T) { + changeCwd(t, "./testdata/pytest") + pytest := NewPytest(RunnerConfig{}) got, err := pytest.GetFiles() @@ -147,8 +149,8 @@ func TestPytestGetFiles(t *testing.T) { } want := []string{ - "testdata/pytest/failed_test.py", - "testdata/pytest/test_sample.py", + "failed_test.py", + "test_sample.py", } if diff := cmp.Diff(got, want); diff != "" { diff --git a/internal/runner/testdata/pytest_pants/.gitignore b/internal/runner/testdata/pytest_pants/.gitignore new file mode 100644 index 00000000..8ce64a32 --- /dev/null +++ b/internal/runner/testdata/pytest_pants/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +.pytest_cache/ +.pants.d/ diff --git a/internal/runner/testdata/pytest_pants/3rdparty/python/BUILD b/internal/runner/testdata/pytest_pants/3rdparty/python/BUILD new file mode 100644 index 00000000..3c6a639f --- /dev/null +++ b/internal/runner/testdata/pytest_pants/3rdparty/python/BUILD @@ -0,0 +1,8 @@ +python_requirements( + name="pytest", + source="pytest-requirements.txt", + resolve="pytest", + overrides={ + "pytest": {"entry_point": "pytest:console_main"}, + }, +) diff --git a/internal/runner/testdata/pytest_pants/3rdparty/python/pytest-requirements.txt b/internal/runner/testdata/pytest_pants/3rdparty/python/pytest-requirements.txt new file mode 100644 index 00000000..fd50fb91 --- /dev/null +++ b/internal/runner/testdata/pytest_pants/3rdparty/python/pytest-requirements.txt @@ -0,0 +1,2 @@ +buildkite-test-collector>=1.0.4,<2.0.0 +pytest>=8.0.0,<9.0.0 diff --git a/internal/runner/testdata/pytest_pants/3rdparty/python/pytest.lock b/internal/runner/testdata/pytest_pants/3rdparty/python/pytest.lock new file mode 100644 index 00000000..773ae665 --- /dev/null +++ b/internal/runner/testdata/pytest_pants/3rdparty/python/pytest.lock @@ -0,0 +1,737 @@ +// This lockfile was autogenerated by Pants. To regenerate, run: +// +// pants generate-lockfiles --resolve=pytest +// +// --- BEGIN PANTS LOCKFILE METADATA: DO NOT EDIT OR REMOVE --- +// { +// "version": 3, +// "valid_for_interpreter_constraints": [ +// "CPython<3.14,>=3.10" +// ], +// "generated_with_requirements": [ +// "buildkite-test-collector<2.0.0,>=1.0.4", +// "pytest<9.0.0,>=8.0.0" +// ], +// "manylinux": "manylinux2014", +// "requirement_constraints": [], +// "only_binary": [], +// "no_binary": [] +// } +// --- END PANTS LOCKFILE METADATA --- + +{ + "allow_builds": true, + "allow_prereleases": false, + "allow_wheels": true, + "build_isolation": true, + "constraints": [], + "elide_unused_requires_dist": false, + "excluded": [], + "locked_resolves": [ + { + "locked_requirements": [ + { + "artifacts": [ + { + "algorithm": "sha256", + "hash": "c2c2e3024fbb6e707267167043bb84186c9e91247c0bbfada47c576f9c43691d", + "url": "https://files.pythonhosted.org/packages/c9/a2/ed10d2b3eb6cd683301d07824cef8b2248211fbdc0fd23479ea8e58d9b4e/buildkite_test_collector-1.0.4-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "4f62e6278bcd85848882bfa635490e3e5f5ecffa9661f389fa1da77e529935ed", + "url": "https://files.pythonhosted.org/packages/0f/34/78dbd7919e5908e192597c1c08d670eb590413b57c50e8affd48b85ed156/buildkite_test_collector-1.0.4.tar.gz" + } + ], + "project_name": "buildkite-test-collector", + "requires_dists": [ + "check-manifest; extra == \"dev\"", + "filelock>=3", + "mock>=4; extra == \"dev\"", + "pylint; extra == \"dev\"", + "pytest>=7", + "requests>=2", + "responses; extra == \"dev\"", + "twine; extra == \"dev\"" + ], + "requires_python": ">=3.8", + "version": "1.0.4" + }, + { + "artifacts": [ + { + "algorithm": "sha256", + "hash": "30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", + "url": "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", + "url": "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz" + } + ], + "project_name": "certifi", + "requires_dists": [], + "requires_python": ">=3.6", + "version": "2025.4.26" + }, + { + "artifacts": [ + { + "algorithm": "sha256", + "hash": "7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", + "url": "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", + "url": "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" + }, + { + "algorithm": "sha256", + "hash": "be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", + "url": "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl" + }, + { + "algorithm": "sha256", + "hash": "cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", + "url": "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl" + }, + { + "algorithm": "sha256", + "hash": "a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", + "url": "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl" + }, + { + "algorithm": "sha256", + "hash": "3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", + "url": "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" + }, + { + "algorithm": "sha256", + "hash": "b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", + "url": "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl" + }, + { + "algorithm": "sha256", + "hash": "32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", + "url": "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl" + }, + { + "algorithm": "sha256", + "hash": "68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", + "url": "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl" + }, + { + "algorithm": "sha256", + "hash": "18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", + "url": "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl" + }, + { + "algorithm": "sha256", + "hash": "aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", + "url": "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" + }, + { + "algorithm": "sha256", + "hash": "8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", + "url": "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl" + }, + { + "algorithm": "sha256", + "hash": "a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", + "url": "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl" + }, + { + "algorithm": "sha256", + "hash": "6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", + "url": "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", + "url": "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl" + }, + { + "algorithm": "sha256", + "hash": "fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", + "url": "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" + }, + { + "algorithm": "sha256", + "hash": "d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", + "url": "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" + }, + { + "algorithm": "sha256", + "hash": "21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", + "url": "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", + "url": "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl" + }, + { + "algorithm": "sha256", + "hash": "dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", + "url": "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", + "url": "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", + "url": "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" + }, + { + "algorithm": "sha256", + "hash": "d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", + "url": "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl" + }, + { + "algorithm": "sha256", + "hash": "efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", + "url": "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl" + }, + { + "algorithm": "sha256", + "hash": "4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", + "url": "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", + "url": "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl" + }, + { + "algorithm": "sha256", + "hash": "fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", + "url": "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", + "url": "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl" + }, + { + "algorithm": "sha256", + "hash": "0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", + "url": "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl" + }, + { + "algorithm": "sha256", + "hash": "0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", + "url": "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl" + }, + { + "algorithm": "sha256", + "hash": "8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", + "url": "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", + "url": "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl" + }, + { + "algorithm": "sha256", + "hash": "98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", + "url": "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl" + }, + { + "algorithm": "sha256", + "hash": "7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", + "url": "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl" + }, + { + "algorithm": "sha256", + "hash": "e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", + "url": "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl" + }, + { + "algorithm": "sha256", + "hash": "7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", + "url": "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl" + }, + { + "algorithm": "sha256", + "hash": "0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", + "url": "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl" + }, + { + "algorithm": "sha256", + "hash": "b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", + "url": "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" + }, + { + "algorithm": "sha256", + "hash": "28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", + "url": "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl" + }, + { + "algorithm": "sha256", + "hash": "6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", + "url": "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", + "url": "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl" + }, + { + "algorithm": "sha256", + "hash": "5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", + "url": "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz" + }, + { + "algorithm": "sha256", + "hash": "926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", + "url": "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl" + }, + { + "algorithm": "sha256", + "hash": "ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", + "url": "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl" + }, + { + "algorithm": "sha256", + "hash": "cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", + "url": "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" + }, + { + "algorithm": "sha256", + "hash": "bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", + "url": "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl" + } + ], + "project_name": "charset-normalizer", + "requires_dists": [], + "requires_python": ">=3.7", + "version": "3.4.2" + }, + { + "artifacts": [ + { + "algorithm": "sha256", + "hash": "4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", + "url": "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", + "url": "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz" + } + ], + "project_name": "exceptiongroup", + "requires_dists": [ + "pytest>=6; extra == \"test\"", + "typing-extensions>=4.6.0; python_version < \"3.13\"" + ], + "requires_python": ">=3.7", + "version": "1.3.0" + }, + { + "artifacts": [ + { + "algorithm": "sha256", + "hash": "c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", + "url": "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", + "url": "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz" + } + ], + "project_name": "filelock", + "requires_dists": [ + "covdefaults>=2.3; extra == \"testing\"", + "coverage>=7.6.10; extra == \"testing\"", + "diff-cover>=9.2.1; extra == \"testing\"", + "furo>=2024.8.6; extra == \"docs\"", + "pytest-asyncio>=0.25.2; extra == \"testing\"", + "pytest-cov>=6; extra == \"testing\"", + "pytest-mock>=3.14; extra == \"testing\"", + "pytest-timeout>=2.3.1; extra == \"testing\"", + "pytest>=8.3.4; extra == \"testing\"", + "sphinx-autodoc-typehints>=3; extra == \"docs\"", + "sphinx>=8.1.3; extra == \"docs\"", + "typing-extensions>=4.12.2; python_version < \"3.11\" and extra == \"typing\"", + "virtualenv>=20.28.1; extra == \"testing\"" + ], + "requires_python": ">=3.9", + "version": "3.18.0" + }, + { + "artifacts": [ + { + "algorithm": "sha256", + "hash": "946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", + "url": "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", + "url": "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz" + } + ], + "project_name": "idna", + "requires_dists": [ + "flake8>=7.1.1; extra == \"all\"", + "mypy>=1.11.2; extra == \"all\"", + "pytest>=8.3.2; extra == \"all\"", + "ruff>=0.6.2; extra == \"all\"" + ], + "requires_python": ">=3.6", + "version": "3.10" + }, + { + "artifacts": [ + { + "algorithm": "sha256", + "hash": "9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", + "url": "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", + "url": "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz" + } + ], + "project_name": "iniconfig", + "requires_dists": [], + "requires_python": ">=3.8", + "version": "2.1.0" + }, + { + "artifacts": [ + { + "algorithm": "sha256", + "hash": "29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "url": "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", + "url": "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz" + } + ], + "project_name": "packaging", + "requires_dists": [], + "requires_python": ">=3.8", + "version": "25.0" + }, + { + "artifacts": [ + { + "algorithm": "sha256", + "hash": "e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", + "url": "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", + "url": "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz" + } + ], + "project_name": "pluggy", + "requires_dists": [ + "coverage; extra == \"testing\"", + "pre-commit; extra == \"dev\"", + "pytest-benchmark; extra == \"testing\"", + "pytest; extra == \"testing\"", + "tox; extra == \"dev\"" + ], + "requires_python": ">=3.9", + "version": "1.6.0" + }, + { + "artifacts": [ + { + "algorithm": "sha256", + "hash": "9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", + "url": "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", + "url": "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz" + } + ], + "project_name": "pygments", + "requires_dists": [ + "colorama>=0.4.6; extra == \"windows-terminal\"" + ], + "requires_python": ">=3.8", + "version": "2.19.1" + }, + { + "artifacts": [ + { + "algorithm": "sha256", + "hash": "f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", + "url": "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", + "url": "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz" + } + ], + "project_name": "pytest", + "requires_dists": [ + "argcomplete; extra == \"dev\"", + "attrs>=19.2; extra == \"dev\"", + "colorama>=0.4; sys_platform == \"win32\"", + "exceptiongroup>=1; python_version < \"3.11\"", + "hypothesis>=3.56; extra == \"dev\"", + "iniconfig>=1", + "mock; extra == \"dev\"", + "packaging>=20", + "pluggy<2,>=1.5", + "pygments>=2.7.2", + "requests; extra == \"dev\"", + "setuptools; extra == \"dev\"", + "tomli>=1; python_version < \"3.11\"", + "xmlschema; extra == \"dev\"" + ], + "requires_python": ">=3.9", + "version": "8.4.0" + }, + { + "artifacts": [ + { + "algorithm": "sha256", + "hash": "27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", + "url": "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", + "url": "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz" + } + ], + "project_name": "requests", + "requires_dists": [ + "PySocks!=1.5.7,>=1.5.6; extra == \"socks\"", + "certifi>=2017.4.17", + "chardet<6,>=3.0.2; extra == \"use-chardet-on-py3\"", + "charset_normalizer<4,>=2", + "idna<4,>=2.5", + "urllib3<3,>=1.21.1" + ], + "requires_python": ">=3.8", + "version": "2.32.4" + }, + { + "artifacts": [ + { + "algorithm": "sha256", + "hash": "cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", + "url": "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", + "url": "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl" + }, + { + "algorithm": "sha256", + "hash": "f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", + "url": "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", + "url": "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl" + }, + { + "algorithm": "sha256", + "hash": "a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", + "url": "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", + "url": "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz" + }, + { + "algorithm": "sha256", + "hash": "c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", + "url": "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl" + }, + { + "algorithm": "sha256", + "hash": "d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", + "url": "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl" + }, + { + "algorithm": "sha256", + "hash": "678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", + "url": "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", + "url": "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", + "url": "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl" + }, + { + "algorithm": "sha256", + "hash": "db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", + "url": "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", + "url": "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl" + }, + { + "algorithm": "sha256", + "hash": "8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", + "url": "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl" + }, + { + "algorithm": "sha256", + "hash": "33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", + "url": "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", + "url": "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", + "url": "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl" + }, + { + "algorithm": "sha256", + "hash": "a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", + "url": "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" + }, + { + "algorithm": "sha256", + "hash": "6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", + "url": "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", + "url": "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl" + }, + { + "algorithm": "sha256", + "hash": "02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", + "url": "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl" + }, + { + "algorithm": "sha256", + "hash": "286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", + "url": "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl" + }, + { + "algorithm": "sha256", + "hash": "023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", + "url": "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl" + }, + { + "algorithm": "sha256", + "hash": "4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", + "url": "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" + }, + { + "algorithm": "sha256", + "hash": "9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", + "url": "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", + "url": "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" + } + ], + "project_name": "tomli", + "requires_dists": [], + "requires_python": ">=3.8", + "version": "2.2.1" + }, + { + "artifacts": [ + { + "algorithm": "sha256", + "hash": "a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", + "url": "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", + "url": "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz" + } + ], + "project_name": "typing-extensions", + "requires_dists": [], + "requires_python": ">=3.9", + "version": "4.14.0" + }, + { + "artifacts": [ + { + "algorithm": "sha256", + "hash": "4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", + "url": "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", + "url": "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz" + } + ], + "project_name": "urllib3", + "requires_dists": [ + "brotli>=1.0.9; platform_python_implementation == \"CPython\" and extra == \"brotli\"", + "brotlicffi>=0.8.0; platform_python_implementation != \"CPython\" and extra == \"brotli\"", + "h2<5,>=4; extra == \"h2\"", + "pysocks!=1.5.7,<2.0,>=1.5.6; extra == \"socks\"", + "zstandard>=0.18.0; extra == \"zstd\"" + ], + "requires_python": ">=3.9", + "version": "2.4.0" + } + ], + "platform_tag": null + } + ], + "only_builds": [], + "only_wheels": [], + "overridden": [], + "path_mappings": {}, + "pex_version": "2.33.4", + "pip_version": "24.2", + "prefer_older_binary": false, + "requirements": [ + "buildkite-test-collector<2.0.0,>=1.0.4", + "pytest<9.0.0,>=8.0.0" + ], + "requires_python": [ + "<3.14,>=3.10" + ], + "resolver_version": "pip-2020-resolver", + "style": "universal", + "target_systems": [ + "linux", + "mac" + ], + "transitive": true, + "use_pep517": null, + "use_system_time": false +} diff --git a/internal/runner/testdata/pytest_pants/BUILD b/internal/runner/testdata/pytest_pants/BUILD new file mode 100644 index 00000000..dabf212d --- /dev/null +++ b/internal/runner/testdata/pytest_pants/BUILD @@ -0,0 +1 @@ +python_tests() diff --git a/internal/runner/testdata/pytest_pants/README.md b/internal/runner/testdata/pytest_pants/README.md new file mode 100644 index 00000000..c17bc3e7 --- /dev/null +++ b/internal/runner/testdata/pytest_pants/README.md @@ -0,0 +1,67 @@ +# pytest_pants + +This directory contains a working example of a +[Pants](https://www.pantsbuild.org/) project using test-engine-client. It +demonstrates how to integrate Pants with +[buildkite-test-collector][bk-test-collector] and test-engine-client. + +## What is Pants? + +[Pants](https://www.pantsbuild.org/) is a fast, scalable, user-friendly build +system for codebases of all sizes. It's particularly useful for: + +- Managing Python dependencies and virtual environments +- Running tests at scale across large codebases +- Incremental builds and testing (only test what changed) +- Enforcing consistent tooling and standards + +## Key Configuration Files + +This example shows the essential files needed for Pants + pytest integration: + +- **`pants.toml`** - Main Pants configuration file that defines: + - Python version constraints + - Backend plugins (enables Python support) + - Resolve configuration for dependency management +- **`3rdparty/python/BUILD`** - Defines Python requirements as Pants targets +- **`3rdparty/python/pytest-requirements.txt`** - Standard pip requirements file +- **`3rdparty/python/pytest.lock`** - Generated lockfile ensuring reproducible builds +- **`BUILD`** - Tells Pants about Python tests in this directory + +## Quick Start + +1. **Install Pants** (if not already installed): + ```sh + curl --proto '=https' --tlsv1.2 -fsSL https://static.pantsbuild.org/setup/get-pants.sh | bash + ``` + +2. **Generate lockfiles** (after adding new dependencies): + ```sh + pants generate-lockfiles --resolve=pytest + ``` + +3. **Run tests**: + ```sh + pants test :: # Run all tests + pants test //path/to/specific:test # Run specific test + ``` + +## Integration with test-engine-client + +When using with buildkite-test-collector and test-engine-client: + +- Set `BUILDKITE_TEST_ENGINE_TEST_RUNNER=pytest-pants` +- The `buildkite-test-collector` package must be included in your pytest resolve +- Use pants-specific test commands that include the required `--json` and `--merge-json` flags + +See the main [pytest-pants documentation](../../../docs/pytest-pants.md) for complete integration details. + +## Updates to pytest pants resolve lock file + +This lock file is what is used by tests. Updating this is particularly useful if the changes being made require a newer version of [buildkite-test-collector][bk-test-collector]. + +```sh +pants generate-lockfiles --resolve=pytest +``` + +[bk-test-collector]: https://pypi.org/project/buildkite-test-collector/ diff --git a/internal/runner/testdata/pytest_pants/failing_test.py b/internal/runner/testdata/pytest_pants/failing_test.py new file mode 100644 index 00000000..3fb47785 --- /dev/null +++ b/internal/runner/testdata/pytest_pants/failing_test.py @@ -0,0 +1,2 @@ +def test_failed(): + assert 3 == 5 diff --git a/internal/runner/testdata/pytest_pants/pants.toml b/internal/runner/testdata/pytest_pants/pants.toml new file mode 100644 index 00000000..db781e82 --- /dev/null +++ b/internal/runner/testdata/pytest_pants/pants.toml @@ -0,0 +1,20 @@ +[GLOBAL] +pants_version = "2.26.0" + +backend_packages = [ + "pants.backend.python", +] + +[python] +interpreter_constraints = [">=3.10,<3.14"] +resolves_generate_lockfiles = true +enable_resolves = true +default_resolve = "pytest" + + +[pytest] +install_from_resolve = "pytest" +requirements = ["//3rdparty/python:pytest"] + +[python.resolves] +pytest = "3rdparty/python/pytest.lock" diff --git a/internal/runner/testdata/pytest_pants/passing_test.py b/internal/runner/testdata/pytest_pants/passing_test.py new file mode 100644 index 00000000..5d60784e --- /dev/null +++ b/internal/runner/testdata/pytest_pants/passing_test.py @@ -0,0 +1,2 @@ +def test_happy(): + assert 3 == 3 diff --git a/internal/runner/testdata/pytest_pants/result-failed.json b/internal/runner/testdata/pytest_pants/result-failed.json new file mode 100644 index 00000000..782ee899 --- /dev/null +++ b/internal/runner/testdata/pytest_pants/result-failed.json @@ -0,0 +1 @@ +[{"id": "a1be7e52-0dba-4018-83ce-a1598ca68807", "scope": "tests/failing_test.py", "name": "test_failed", "location": "tests/failing_test.py:0", "file_name": "tests/failing_test.py", "history": {"section": "top", "children": [], "start_at": 2e-05, "end_at": 0.012478, "duration": 0.012458}, "result": "failed", "failure_reason": "def test_failed():\n> assert 3 == 5\nE assert 3 == 5\n\ntests/failing_test.py:2: AssertionError"}] \ No newline at end of file diff --git a/main.go b/main.go index 4b0e00da..080bd663 100644 --- a/main.go +++ b/main.go @@ -393,7 +393,7 @@ func createRequestParam(ctx context.Context, cfg config.Config, files []string, // Splitting files by example is only supported for rspec runner. if runner.Name() != "RSpec" { - return api.TestPlanParams{ + params := api.TestPlanParams{ Identifier: cfg.Identifier, Parallelism: cfg.Parallelism, Branch: cfg.Branch, @@ -401,7 +401,17 @@ func createRequestParam(ctx context.Context, cfg config.Config, files []string, Tests: api.TestPlanParamsTest{ Files: testFiles, }, - }, nil + } + + // This is a workaround for the fact that the pytest-pants runner is not + // supported by the Test Engine API. For now, we use the pytest runner. At + // some point, there may be a difference between the two runners, but for + // now the response from the Test Engine API is the same for both runners. + if cfg.TestRunner == "pytest-pants" { + params.Runner = "pytest" + } + + return params, nil } if cfg.SplitByExample { diff --git a/main_test.go b/main_test.go index 7d379fca..d1f1d44e 100644 --- a/main_test.go +++ b/main_test.go @@ -810,6 +810,66 @@ func TestCreateRequestParams_NonRSpec(t *testing.T) { } } +func TestCreateRequestParams_PytestPants(t *testing.T) { + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, ` +{ + "tests": [ + { "path": "test/banana_test.py", "reason": "slow file" }, + { "path": "test/fig_test.py", "reason": "slow file" } + ] +}`) + })) + defer svr.Close() + + runner := runner.PytestPants{} + + t.Run(runner.Name(), func(t *testing.T) { + cfg := config.Config{ + OrganizationSlug: "my-org", + SuiteSlug: "my-suite", + Identifier: "identifier", + Parallelism: 7, + Branch: "", + TestRunner: runner.Name(), + Env: env.Map{}, + } + + client := api.NewClient(api.ClientConfig{ + ServerBaseUrl: svr.URL, + }) + files := []string{ + "test/apple_test.py", + "test/banana_test.py", + "test/cherry_test.py", + } + + got, err := createRequestParam(context.Background(), cfg, files, *client, runner) + + if err != nil { + t.Errorf("createRequestParam() error = %v", err) + } + + want := api.TestPlanParams{ + Identifier: "identifier", + Parallelism: 7, + Branch: "", + Runner: "pytest", + Tests: api.TestPlanParamsTest{ + Files: []plan.TestCase{ + {Path: "test/apple_test.py"}, + {Path: "test/banana_test.py"}, + {Path: "test/cherry_test.py"}, + }, + }, + } + + if diff := cmp.Diff(got, want); diff != "" { + t.Errorf("createRequestParam() diff (-got +want):\n%s", diff) + } + }) +} + func TestCreateRequestParams_FilterTestsError(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, `{ "message": "forbidden" }`, http.StatusForbidden)