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
53 changes: 50 additions & 3 deletions pkg/runtime/runtime.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -385,19 +385,34 @@ func applyPythonFormulaTestScope(req *runtimev0.TestRequest, spec *pythonhelpers
// owns pytest's private distinction between positional collection targets
// and -k name expressions.
if spec.Output == pythonhelpers.OutputJUnitXML {
var exactSelectors, namePatterns []string
target := strings.TrimSpace(req.GetTarget())
var exactSelectors, relativeNodeSelectors, namePatterns []string
for _, filter := range req.GetFilters() {
if isExactPytestSelector(filter) {
if isRelativePytestNodeSelector(filter) {
relativeNodeSelectors = append(relativeNodeSelectors, strings.TrimSpace(filter))
} else if isExactPytestSelector(filter) {
exactSelectors = append(exactSelectors, filter)
} else if strings.TrimSpace(filter) != "" {
namePatterns = append(namePatterns, filter)
}
}
if len(relativeNodeSelectors) > 0 {
if !isPytestFileTarget(target) {
return fmt.Errorf("Python relative node selectors %q require one .py target, got %q", relativeNodeSelectors, target)
}
if len(exactSelectors) > 0 || len(namePatterns) > 0 {
return fmt.Errorf("Python relative node selectors cannot be mixed with independent exact selectors or name filters; use a typed TestSelection")
}
for _, selector := range relativeNodeSelectors {
exactSelectors = append(exactSelectors, target+"::"+strings.TrimPrefix(selector, "::"))
}
target = ""
}
nameFilters, err := normalizePytestNameFilters(namePatterns)
if err != nil {
return err
}
if target := strings.TrimSpace(req.GetTarget()); target != "" {
if target != "" {
exactSelectors = append(exactSelectors, target)
}
if len(exactSelectors) > 0 {
Expand DownExpand Up@@ -441,11 +456,37 @@ func isExactPytestSelector(value string) bool {
return strings.HasSuffix(normalized, ".py") || strings.Contains(normalized, ".py/")
}

// isRelativePytestNodeSelector recognizes the pytest identity suffix callers
// naturally pair with TestRequest.target: `Class::method`. It is not a valid
// positional operand on its own. The runtime joins it to the file target so an
// identical logical request cannot accidentally execute two unrelated
// selectors and collect zero cases.
func isRelativePytestNodeSelector(value string) bool {
value = strings.TrimSpace(value)
if !strings.Contains(value, "::") {
return false
}
return !isPytestFileTarget(strings.SplitN(value, "::", 2)[0])
}

func isPytestFileTarget(value string) bool {
value = strings.TrimSpace(strings.ReplaceAll(value, `\`, "/"))
return value != "" && !strings.Contains(value, "::") && strings.HasSuffix(value, ".py")
}

func pythonDefaultTestScope(req *runtimev0.TestRequest) (string, []string, error) {
if req.GetSelection() == nil {
target := strings.TrimSpace(req.GetTarget())
namePatterns := make([]string, 0, len(req.GetFilters()))
var relativeNode string
for _, filter := range req.GetFilters() {
if isRelativePytestNodeSelector(filter) {
if relativeNode != "" {
return "", nil, fmt.Errorf("Python default test request has multiple relative node selectors; use one typed TestSelection")
}
relativeNode = strings.TrimSpace(filter)
continue
}
if !isExactPytestSelector(filter) {
namePatterns = append(namePatterns, filter)
continue
Expand All@@ -455,6 +496,12 @@ func pythonDefaultTestScope(req *runtimev0.TestRequest) (string, []string, error
}
target = strings.TrimSpace(filter)
}
if relativeNode != "" {
if !isPytestFileTarget(target) || len(namePatterns) > 0 {
return "", nil, fmt.Errorf("Python relative node selector %q requires one .py target and no name filters; use a typed TestSelection", relativeNode)
}
target += "::" + strings.TrimPrefix(relativeNode, "::")
}
filters, err := normalizePytestNameFilters(namePatterns)
return target, filters, err
}
Expand Down
40 changes: 40 additions & 0 deletions pkg/runtime/runtime_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -185,6 +185,46 @@ commands = pytest --pyargs broadpkg {posargs}
assertRuntimeTestLeftSourceClean(t, root)
}

// TestRuntimeFormulaCombinesFileTargetAndRelativeNodeFilter is the exact
// production request shape emitted by Mind's regression-test capability. A
// relative `Class::method` filter belongs beneath the file target; passing the
// two strings as independent pytest operands collects zero tests.
func TestRuntimeFormulaCombinesFileTargetAndRelativeNodeFilter(t *testing.T) {
if _, err := exec.LookPath("uv"); err != nil {
t.Fatalf("uv is required for the production Python runtime: %v", err)
}
root := t.TempDir()
for path, content := range map[string]string{
"tox.ini": `[testenv]
commands = pytest {posargs}
`,
"test_selected.py": `class TestThing:
def test_selected_pass(self):
assert 2 + 2 == 4

def test_unselected_fail(self):
assert False
`,
} {
if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(path)), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}

svc := pythonservice.New(&resources.Agent{Kind: "codefly:service", Name: "python"})
svc.SourceLocation = root
resp, err := pythonruntime.New(svc).Test(context.Background(), &runtimev0.TestRequest{
Target: "test_selected.py", Filters: []string{"TestThing::test_selected_pass"},
})
if err != nil {
t.Fatalf("Test: %v", err)
}
if resp.GetResult().GetState() != runtimev0.TestRunResult_PASSED || resp.GetCounts().GetTotal() != 1 || resp.GetCounts().GetPassed() != 1 {
t.Fatalf("relative-node result = %s counts=%+v, want one selected passing case\n%s", resp.GetResult().GetState(), resp.GetCounts(), resp.GetOutput())
}
assertRuntimeTestLeftSourceClean(t, root)
}

// TestRuntimeFormulaExactFilterReplacesBroadPytestDiscovery proves exact node
// identities carried in the repeated filters field remain exact targets. This
// is the shape used by result-driven graders that own a set of test identities.
Expand Down
Loading