diff --git a/runners/python/pytest.go b/runners/python/pytest.go index 2f161680..55d7c1db 100644 --- a/runners/python/pytest.go +++ b/runners/python/pytest.go @@ -187,29 +187,31 @@ func RunPythonTestsStructured(ctx context.Context, sourceDir string, envVars []* defer os.RemoveAll(junitDir) junitFile := filepath.Join(junitDir, fmt.Sprintf("pytest-junit-%d.xml", time.Now().UnixNano())) - // --no-project prevents uv from creating/updating uv.lock or .venv in the - // user's checkout. Pytest and an optional editable project overlay are - // materialized in uv's external cache. - pytestArgs := []string{"run", "--no-project", "--with", "pytest"} - if info, statErr := os.Stat(filepath.Join(sourceDir, "pyproject.toml")); statErr == nil && !info.IsDir() { - pytestArgs = append(pytestArgs, "--with-editable", ".") - } - pytestArgs = append(pytestArgs, - "pytest", - "--tb=short", - "--junitxml="+junitFile, - "-p", "no:cacheprovider", + // ARCHITECTURE: The default pytest adapter is still a real formula. Build it + // through the same project-derived provisioning contract as an explicitly + // declared formula so the two production paths cannot drift. In particular, + // --no-project isolates the checkout but does NOT mean "ignore the project's + // requirements": requirement files, editable packaging, interpreter pins, + // groups, and extras remain project-owned input to uv. + spec := SpecFromFormula( + []string{"pytest"}, + OutputJUnitXML, + nil, + DeriveProvisioning(sourceDir), + nil, ) + spec.Env = append(spec.Env, envVars...) + spec.ExtraArgs = append(spec.ExtraArgs, "--tb=short", "-p", "no:cacheprovider") // Default to verbose unless the caller explicitly set Verbose=false. // Verbose feeds the OnEvent stream; the JUnit XML is parsed regardless. if !opt.VerboseSet || opt.Verbose { - pytestArgs = append(pytestArgs, "-v") + spec.ExtraArgs = append(spec.ExtraArgs, "-v") } // Filters → -k "p1 or p2 or ..." (pytest's expression syntax). if expr := combinePytestK(opt.Filters); expr != "" { - pytestArgs = append(pytestArgs, "-k", expr) + spec.ExtraArgs = append(spec.ExtraArgs, "-k", expr) } // Timeout — pytest-timeout reads --timeout=. Convert Go @@ -217,24 +219,44 @@ func RunPythonTestsStructured(ctx context.Context, sourceDir string, envVars []* // can supply already-formatted values. if opt.Timeout != "" { if d, err := time.ParseDuration(opt.Timeout); err == nil { - pytestArgs = append(pytestArgs, fmt.Sprintf("--timeout=%d", int(d.Seconds()))) + spec.ExtraArgs = append(spec.ExtraArgs, fmt.Sprintf("--timeout=%d", int(d.Seconds()))) } else { - pytestArgs = append(pytestArgs, "--timeout="+opt.Timeout) + spec.ExtraArgs = append(spec.ExtraArgs, "--timeout="+opt.Timeout) } } // Coverage — pytest-cov, scoped to the source tree so we report // numbers for the user's code rather than the test files themselves. if opt.Coverage { - pytestArgs = append(pytestArgs, "--cov=.", "--cov-report=term") + spec.ExtraArgs = append(spec.ExtraArgs, "--cov=.", "--cov-report=term") } // Power-user passthrough. - pytestArgs = append(pytestArgs, opt.ExtraArgs...) + spec.ExtraArgs = append(spec.ExtraArgs, opt.ExtraArgs...) // Target last — pytest treats positional args as collection paths. if opt.Target != "" { - pytestArgs = append(pytestArgs, opt.Target) + spec.Selectors = append(spec.Selectors, opt.Target) + } + + // Keep the adapter environment outside the checkout. Requirement files and + // editable packages work with --no-project. Dependency groups and extras + // require pyproject discovery, so those runs use uv's isolated project mode: + // it resolves the declared sets without creating uv.lock or .venv beside the + // user's source. Persistent formula environments remain an explicit formula + // concern and are never introduced by this read-only default adapter. + if spec.Editable && spec.EditableTarget == "" { + if abs, absErr := filepath.Abs(sourceDir); absErr == nil { + spec.EditableTarget = abs + } + } + projectIsolated := len(spec.DependencyGroups) > 0 || len(spec.Extras) > 0 + if projectIsolated { + spec.NoProject = false + } + pytestArgs := BuildUvArgs(spec, junitFile) + if projectIsolated { + pytestArgs = append([]string{"run", "--isolated"}, pytestArgs[1:]...) } cmd := exec.CommandContext(ctx, "uv", pytestArgs...) @@ -279,7 +301,7 @@ func RunPythonTestsStructured(ctx context.Context, sourceDir string, envVars []* "PYTHONPYCACHEPREFIX="+filepath.Join(junitDir, "pycache"), "COVERAGE_FILE="+filepath.Join(junitDir, "coverage"), ) - for _, ev := range envVars { + for _, ev := range spec.Env { cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", ev.Key, ev.Value)) } @@ -293,6 +315,26 @@ func RunPythonTestsStructured(ctx context.Context, sourceDir string, envVars []* coverage := scrapeCoverageFromOutput(rawStr) run := ParsePytestJUnit(string(xmlBytes), coverage) run.RawOutput = rawStr + // Match formula execution's typed unhappy-path contract. A collection or + // provisioning failure that produces zero cases is an environment error, + // not an opaque process exit for the caller to flatten. Likewise, an empty + // successful invocation is never a passing test run. + if run.caseCount() == 0 { + if runErr != nil { + run.EnvError = ClassifyEnvError(rawStr, runErr) + } else if opt.Target != "" { + run.EnvError = &RunEnvError{ + Reason: EnvErrorNoTestsMatchedSelectors, + Detail: fmt.Sprintf("target %q matched zero tests — the selector does not name any collectible test", opt.Target), + } + } else { + run.EnvError = &RunEnvError{ + Reason: EnvErrorNoTestsExecuted, + Detail: "the default pytest adapter executed zero tests — fix the project test declarations or collection environment", + } + } + runErr = nil + } if opt.CacheDir != "" { if err := writeLastTestOutput(opt.CacheDir, rawStr); err != nil { diff --git a/runners/python/pytest_test.go b/runners/python/pytest_test.go index 3191f5c9..ca3f10cf 100644 --- a/runners/python/pytest_test.go +++ b/runners/python/pytest_test.go @@ -1,13 +1,122 @@ package python import ( + "context" "os" + "os/exec" "path/filepath" "strings" "sync" "testing" + "time" ) +// TestRunPythonTestsStructuredMaterializesDeclaredRequirements is the real +// default-adapter proof: the test imports a separately packaged dependency +// that exists only through requirements.txt. The runner must ask uv to build +// that declared environment; ambient Python and a pytest-only overlay cannot +// make this pass. +func TestRunPythonTestsStructuredMaterializesDeclaredRequirements(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() + dependencyDir := filepath.Join(root, "supportdep") + if err := os.MkdirAll(dependencyDir, 0o755); err != nil { + t.Fatal(err) + } + write := func(path, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, path), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + write("supportdep/pyproject.toml", `[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "codefly-declared-probe-dependency" +version = "0.0.1" + +[tool.setuptools] +py-modules = ["declared_probe_dependency"] +`) + write("supportdep/declared_probe_dependency.py", "VALUE = 'from-declared-requirements'\n") + write("requirements.txt", "./supportdep\n") + write("test_declared_dependency.py", `import declared_probe_dependency + +def test_dependency_was_materialized_from_project_declaration(): + assert declared_probe_dependency.VALUE == "from-declared-requirements" +`) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + run, err := RunPythonTestsStructured(ctx, root, nil, TestOptions{VerboseSet: true}) + if err != nil { + t.Fatalf("RunPythonTestsStructured: %v\n%s", err, run.RawOutput) + } + if run.EnvError != nil { + t.Fatalf("default 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) + } + for _, generated := range []string{"uv.lock", ".venv", ".pytest_cache", "__pycache__"} { + if _, err := os.Stat(filepath.Join(root, generated)); !os.IsNotExist(err) { + t.Fatalf("production runner generated %s in source checkout", generated) + } + } +} + +// 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. +func TestRunPythonTestsStructuredMaterializesDeclaredDependencyGroups(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() + write := func(path, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, path), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + write("pyproject.toml", `[project] +name = "codefly-declared-group-probe" +version = "0.0.1" + +[dependency-groups] +test = ["boltons==24.0.0"] +`) + write("test_declared_group.py", `import boltons + +def test_dependency_group_was_materialized(): + assert boltons is not None +`) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + run, err := RunPythonTestsStructured(ctx, root, nil, TestOptions{VerboseSet: true}) + if err != nil { + t.Fatalf("RunPythonTestsStructured: %v\n%s", err, run.RawOutput) + } + if run.EnvError != nil { + t.Fatalf("default 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) + } + for _, generated := range []string{"uv.lock", ".venv", ".pytest_cache", "__pycache__"} { + if _, err := os.Stat(filepath.Join(root, generated)); !os.IsNotExist(err) { + t.Fatalf("isolated project runner generated %s in source checkout", generated) + } + } +} + // TestScanPytestEvents_EmitsPerLine feeds realistic pytest verbose // output through scanPytestEvents and asserts the callback fires once // per progress line, in order.