From 8dbce189631d14ef00a13dfa278defbd3a5e96ee Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 9 Aug 2026 09:37:21 -0400 Subject: [PATCH] fix(python): materialize typed timeout adapter --- runners/python/pytest.go | 16 +++++++++++----- runners/python/pytest_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/runners/python/pytest.go b/runners/python/pytest.go index 8a99a770..bdc22ee6 100644 --- a/runners/python/pytest.go +++ b/runners/python/pytest.go @@ -131,9 +131,10 @@ type TestOptions struct { // VerboseSet distinguishes "use default" from "explicitly false". VerboseSet bool - // Timeout maps to pytest's --timeout=. Empty leaves the - // default. Accepts Go duration syntax ("30s", "2m") which we coerce - // into seconds for pytest. + // Timeout maps to pytest-timeout's --timeout=. 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 @@ -228,9 +229,14 @@ func RunPythonTestsStructured(ctx context.Context, sourceDir string, envVars []* } // Timeout — pytest-timeout reads --timeout=. 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 { diff --git a/runners/python/pytest_test.go b/runners/python/pytest_test.go index 5ff831b9..8d1016c8 100644 --- a/runners/python/pytest_test.go +++ b/runners/python/pytest_test.go @@ -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.