Skip to content

Commit 5838436

Browse files
fix(runtime): join relative pytest node selectors
1 parent e0df276 commit 5838436

2 files changed

Lines changed: 90 additions & 3 deletions

File tree

‎pkg/runtime/runtime.go‎

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -385,19 +385,34 @@ func applyPythonFormulaTestScope(req *runtimev0.TestRequest, spec *pythonhelpers
385385
// owns pytest's private distinction between positional collection targets
386386
// and -k name expressions.
387387
if spec.Output == pythonhelpers.OutputJUnitXML {
388-
var exactSelectors, namePatterns []string
388+
target := strings.TrimSpace(req.GetTarget())
389+
var exactSelectors, relativeNodeSelectors, namePatterns []string
389390
for _, filter := range req.GetFilters() {
390-
if isExactPytestSelector(filter) {
391+
if isRelativePytestNodeSelector(filter) {
392+
relativeNodeSelectors = append(relativeNodeSelectors, strings.TrimSpace(filter))
393+
} else if isExactPytestSelector(filter) {
391394
exactSelectors = append(exactSelectors, filter)
392395
} else if strings.TrimSpace(filter) != "" {
393396
namePatterns = append(namePatterns, filter)
394397
}
395398
}
399+
if len(relativeNodeSelectors) > 0 {
400+
if !isPytestFileTarget(target) {
401+
return fmt.Errorf("Python relative node selectors %q require one .py target, got %q", relativeNodeSelectors, target)
402+
}
403+
if len(exactSelectors) > 0 || len(namePatterns) > 0 {
404+
return fmt.Errorf("Python relative node selectors cannot be mixed with independent exact selectors or name filters; use a typed TestSelection")
405+
}
406+
for _, selector := range relativeNodeSelectors {
407+
exactSelectors = append(exactSelectors, target+"::"+strings.TrimPrefix(selector, "::"))
408+
}
409+
target = ""
410+
}
396411
nameFilters, err := normalizePytestNameFilters(namePatterns)
397412
if err != nil {
398413
return err
399414
}
400-
if target := strings.TrimSpace(req.GetTarget()); target != "" {
415+
if target != "" {
401416
exactSelectors = append(exactSelectors, target)
402417
}
403418
if len(exactSelectors) > 0 {
@@ -441,11 +456,37 @@ func isExactPytestSelector(value string) bool {
441456
return strings.HasSuffix(normalized, ".py") || strings.Contains(normalized, ".py/")
442457
}
443458

459+
// isRelativePytestNodeSelector recognizes the pytest identity suffix callers
460+
// naturally pair with TestRequest.target: `Class::method`. It is not a valid
461+
// positional operand on its own. The runtime joins it to the file target so an
462+
// identical logical request cannot accidentally execute two unrelated
463+
// selectors and collect zero cases.
464+
func isRelativePytestNodeSelector(value string) bool {
465+
value = strings.TrimSpace(value)
466+
if !strings.Contains(value, "::") {
467+
return false
468+
}
469+
return !isPytestFileTarget(strings.SplitN(value, "::", 2)[0])
470+
}
471+
472+
func isPytestFileTarget(value string) bool {
473+
value = strings.TrimSpace(strings.ReplaceAll(value, `\`, "/"))
474+
return value != "" && !strings.Contains(value, "::") && strings.HasSuffix(value, ".py")
475+
}
476+
444477
func pythonDefaultTestScope(req *runtimev0.TestRequest) (string, []string, error) {
445478
if req.GetSelection() == nil {
446479
target := strings.TrimSpace(req.GetTarget())
447480
namePatterns := make([]string, 0, len(req.GetFilters()))
481+
var relativeNode string
448482
for _, filter := range req.GetFilters() {
483+
if isRelativePytestNodeSelector(filter) {
484+
if relativeNode != "" {
485+
return "", nil, fmt.Errorf("Python default test request has multiple relative node selectors; use one typed TestSelection")
486+
}
487+
relativeNode = strings.TrimSpace(filter)
488+
continue
489+
}
449490
if !isExactPytestSelector(filter) {
450491
namePatterns = append(namePatterns, filter)
451492
continue
@@ -455,6 +496,12 @@ func pythonDefaultTestScope(req *runtimev0.TestRequest) (string, []string, error
455496
}
456497
target = strings.TrimSpace(filter)
457498
}
499+
if relativeNode != "" {
500+
if !isPytestFileTarget(target) || len(namePatterns) > 0 {
501+
return "", nil, fmt.Errorf("Python relative node selector %q requires one .py target and no name filters; use a typed TestSelection", relativeNode)
502+
}
503+
target += "::" + strings.TrimPrefix(relativeNode, "::")
504+
}
458505
filters, err := normalizePytestNameFilters(namePatterns)
459506
return target, filters, err
460507
}

‎pkg/runtime/runtime_test.go‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,46 @@ commands = pytest --pyargs broadpkg {posargs}
185185
assertRuntimeTestLeftSourceClean(t, root)
186186
}
187187

188+
// TestRuntimeFormulaCombinesFileTargetAndRelativeNodeFilter is the exact
189+
// production request shape emitted by Mind's regression-test capability. A
190+
// relative `Class::method` filter belongs beneath the file target; passing the
191+
// two strings as independent pytest operands collects zero tests.
192+
func TestRuntimeFormulaCombinesFileTargetAndRelativeNodeFilter(t *testing.T) {
193+
if _, err := exec.LookPath("uv"); err != nil {
194+
t.Fatalf("uv is required for the production Python runtime: %v", err)
195+
}
196+
root := t.TempDir()
197+
for path, content := range map[string]string{
198+
"tox.ini": `[testenv]
199+
commands = pytest {posargs}
200+
`,
201+
"test_selected.py": `class TestThing:
202+
def test_selected_pass(self):
203+
assert 2 + 2 == 4
204+
205+
def test_unselected_fail(self):
206+
assert False
207+
`,
208+
} {
209+
if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(path)), []byte(content), 0o644); err != nil {
210+
t.Fatal(err)
211+
}
212+
}
213+
214+
svc := pythonservice.New(&resources.Agent{Kind: "codefly:service", Name: "python"})
215+
svc.SourceLocation = root
216+
resp, err := pythonruntime.New(svc).Test(context.Background(), &runtimev0.TestRequest{
217+
Target: "test_selected.py", Filters: []string{"TestThing::test_selected_pass"},
218+
})
219+
if err != nil {
220+
t.Fatalf("Test: %v", err)
221+
}
222+
if resp.GetResult().GetState() != runtimev0.TestRunResult_PASSED || resp.GetCounts().GetTotal() != 1 || resp.GetCounts().GetPassed() != 1 {
223+
t.Fatalf("relative-node result = %s counts=%+v, want one selected passing case\n%s", resp.GetResult().GetState(), resp.GetCounts(), resp.GetOutput())
224+
}
225+
assertRuntimeTestLeftSourceClean(t, root)
226+
}
227+
188228
// TestRuntimeFormulaExactFilterReplacesBroadPytestDiscovery proves exact node
189229
// identities carried in the repeated filters field remain exact targets. This
190230
// is the shape used by result-driven graders that own a set of test identities.

0 commit comments

Comments
 (0)