Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions runners/python/pytest.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,10 @@ type TestOptions struct {
// VerboseSet distinguishes "use default" from "explicitly false".
VerboseSet bool

// Timeout maps to pytest's --timeout=<sec>. Empty leaves the
// default. Accepts Go duration syntax ("30s", "2m") which we coerce
// into seconds for pytest.
// Timeout maps to pytest-timeout's --timeout=<sec>. Empty leaves the
// default. The adapter materializes that plugin in its isolated environment;
// projects never need to declare a Codefly implementation dependency.
// Accepts Go duration syntax ("30s", "2m") which we coerce into seconds.
Timeout string

// Coverage enables coverage instrumentation via pytest-cov when the
Expand Down Expand Up @@ -228,9 +229,14 @@ func RunPythonTestsStructured(ctx context.Context, sourceDir string, envVars []*
}

// Timeout — pytest-timeout reads --timeout=<seconds>. Convert Go
// durations to seconds; pass through anything else verbatim so users
// can supply already-formatted values.
// durations to seconds; pass through anything else verbatim so users can
// supply already-formatted values. This is adapter-owned runner knowledge:
// materialize the real plugin in uv's isolated environment instead of
// requiring every project to carry it or retrying without the typed bound.
if opt.Timeout != "" {
if !containsRequirement(spec.With, "pytest-timeout") {
spec.With = append(spec.With, "pytest-timeout")
}
if d, err := time.ParseDuration(opt.Timeout); err == nil {
spec.ExtraArgs = append(spec.ExtraArgs, fmt.Sprintf("--timeout=%d", int(d.Seconds())))
} else {
Expand Down
31 changes: 31 additions & 0 deletions runners/python/pytest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,37 @@ def test_dependency_was_materialized_from_project_declaration():
assertDefaultRunnerLeftSourceClean(t, root)
}

// TestRunPythonTestsStructuredMaterializesTimeoutAdapter proves the typed
// per-case timeout does not become a project dependency. The production
// adapter owns pytest-timeout and must add it to the isolated uv environment
// even when the project itself declares no such plugin.
func TestRunPythonTestsStructuredMaterializesTimeoutAdapter(t *testing.T) {
if _, err := exec.LookPath("uv"); err != nil {
t.Fatalf("uv is required for the production Python runner: %v", err)
}
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "test_timeout_adapter.py"), []byte(`def test_timeout_adapter_is_available():
assert True
`), 0o644); err != nil {
t.Fatal(err)
}

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
run, err := RunPythonTestsStructured(ctx, root, nil, TestOptions{VerboseSet: true, Timeout: "30s"})
if err != nil {
t.Fatalf("RunPythonTestsStructured: %v\n%s", err, run.RawOutput)
}
if run.EnvError != nil {
t.Fatalf("timeout adapter environment error: %s\n%s", run.EnvError.Detail, run.RawOutput)
}
summary := run.LegacyTestSummary()
if summary.Run != 1 || summary.Passed != 1 || summary.Failed != 0 {
t.Fatalf("summary = %+v, want one passed test\n%s", summary, run.RawOutput)
}
assertDefaultRunnerLeftSourceClean(t, root)
}

// TestRunPythonTestsStructuredMaterializesDeclaredDependencyGroups proves the
// pyproject-backed default path uses uv's isolated project mode. The declared
// group must be available, while uv.lock and .venv remain absent from source.
Expand Down
Loading