Skip to content

Commit 810f529

Browse files
Merge pull request #60 from codefly-dev/codex/formula-test-scope
Bind Python test requests to exact pytest scope
2 parents fad1ee6 + 308409c commit 810f529

5 files changed

Lines changed: 202 additions & 27 deletions

File tree

‎agent.codefly.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
publisher: codefly.dev
22
kind: codefly:service
33
name: python
4-
version: 0.0.44
4+
version: 0.0.45

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ go 1.25.12
55
toolchain go1.26.4
66

77
require (
8-
github.com/codefly-dev/core v0.2.118
8+
github.com/codefly-dev/core v0.2.119
99
github.com/scagogogo/python-requirements-parser v0.0.0-20250717025652-6ca77234c827
1010
gopkg.in/yaml.v3 v3.0.1
1111
)

‎go.sum‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
3737
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
3838
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
3939
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
40-
github.com/codefly-dev/core v0.2.118 h1:oy7TUKPLxfpcOC1C3NnZ5ktEctw4ZidCoBjHfLgCu+U=
41-
github.com/codefly-dev/core v0.2.118/go.mod h1:e3xNBpBgMOzraxGaFbbiYtMYyhLAEoP7Cy2yaMrvV5U=
40+
github.com/codefly-dev/core v0.2.119 h1:krAElUdbrafbqNghApTVReeKrzrkzPpQ0TQ8oQBq6CY=
41+
github.com/codefly-dev/core v0.2.119/go.mod h1:e3xNBpBgMOzraxGaFbbiYtMYyhLAEoP7Cy2yaMrvV5U=
4242
github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ=
4343
github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg=
4444
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=

‎pkg/runtime/runtime.go‎

Lines changed: 78 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -129,23 +129,13 @@ func (s *Runtime) Test(ctx context.Context, req *runtimev0.TestRequest) (*runtim
129129
// plugin is the ONLY place the generic provisioning map becomes uv flags.
130130
if cmd, output, env, prov, ok := resolveTestFormula(req, s.Base.Service, s.Service.SourceLocation); ok {
131131
fspec := pythonhelpers.SpecFromFormula(cmd, output, env, prov, nil)
132-
selectors, selectorErr := pythonTestRequestSelectors(req, fspec.Command, fspec.Cwd)
133-
if selectorErr != nil {
132+
if selectorErr := applyPythonFormulaTestScope(req, &fspec); selectorErr != nil {
134133
return nil, fmt.Errorf("python runtime selection: %w", selectorErr)
135134
}
136-
// A typed selector is authoritative execution scope. Derived pytest
137-
// commands may carry broad project discovery operands (for example,
138-
// `pytest --pyargs astropy docs {posargs}`). Appending one node ID to
139-
// those operands still collects unrelated modules, allowing their
140-
// failures to masquerade as the selected case. Keep the real runner
141-
// prefix and replace broad pytest discovery before adding the exact
142-
// plugin-rendered selector.
143-
if req.GetSelection() != nil {
144-
fspec.Command = pythonhelpers.CommandForExactSelection(fspec.Command)
145-
}
146-
fspec.Selectors = selectors
147135
s.Wool.Info("running test formula via uv",
148-
wool.Field("command", fspec.Command), wool.Field("output", fspec.Output))
136+
wool.Field("command", fspec.Command),
137+
wool.Field("selectors", fspec.Selectors),
138+
wool.Field("output", fspec.Output))
149139
fStart := time.Now()
150140
fRun, fErr := pythonhelpers.RunFormulaStructured(ctx, s.Service.SourceLocation, fspec)
151141
evidence := pythonhelpers.RuntimeEvidenceForFormula(s.Service.SourceLocation, cmd, output, env, prov, formulaDerivedFromProject(req, s.Base.Service))
@@ -368,18 +358,83 @@ func boundedTestLogMessage(message string) string {
368358
) + message[tailStart:]
369359
}
370360

371-
// pythonTestRequestSelectors translates authoritative typed scope inside the
372-
// Python plugin. Legacy target/filter requests remain supported for interactive
373-
// broad runs, but never participate in the typed acknowledgement contract.
374-
func pythonTestRequestSelectors(req *runtimev0.TestRequest, command []string, cwd string) ([]string, error) {
361+
// applyPythonFormulaTestScope translates the language-neutral runtime request
362+
// into runner-private formula arguments. This is the boundary that owns the
363+
// distinction between pytest collection targets and pytest name expressions:
364+
// callers provide target/filter data and never construct runner flags.
365+
//
366+
// ARCHITECTURE: A target or exact pytest node identity is authoritative scope.
367+
// Derived formulas may contain broad discovery operands such as
368+
// `pytest --pyargs astropy docs`; retaining those operands would execute
369+
// unrelated tests in addition to the requested target. Exact scope therefore
370+
// replaces broad discovery, while ordinary filters preserve the derived scope
371+
// and become pytest's name-expression argument. Typed TestSelection remains
372+
// the acknowledged, fail-closed acceptance contract.
373+
func applyPythonFormulaTestScope(req *runtimev0.TestRequest, spec *pythonhelpers.TestFormulaSpec) error {
375374
if req.GetSelection() != nil {
376-
return pythonhelpers.RenderTestSelection(req.GetSelection(), command, cwd)
375+
selectors, err := pythonhelpers.RenderTestSelection(req.GetSelection(), spec.Command, spec.Cwd)
376+
if err != nil {
377+
return err
378+
}
379+
spec.Command = pythonhelpers.CommandForExactSelection(spec.Command)
380+
spec.Selectors = selectors
381+
return nil
382+
}
383+
384+
// JUnit XML selects Codefly's pytest adapter. The Python plugin, not Mind,
385+
// owns pytest's private distinction between positional collection targets
386+
// and -k name expressions.
387+
if spec.Output == pythonhelpers.OutputJUnitXML {
388+
var exactSelectors, nameFilters []string
389+
for _, filter := range req.GetFilters() {
390+
if isExactPytestSelector(filter) {
391+
exactSelectors = append(exactSelectors, filter)
392+
} else if strings.TrimSpace(filter) != "" {
393+
nameFilters = append(nameFilters, filter)
394+
}
395+
}
396+
if target := strings.TrimSpace(req.GetTarget()); target != "" {
397+
exactSelectors = append(exactSelectors, target)
398+
}
399+
if len(exactSelectors) > 0 {
400+
spec.Command = pythonhelpers.CommandForExactSelection(spec.Command)
401+
}
402+
if len(nameFilters) > 0 {
403+
spec.ExtraArgs = append(spec.ExtraArgs, "-k", strings.Join(nameFilters, " or "))
404+
// A filter-only request is a complete test execution even though it
405+
// has no positional selector. Say so explicitly: formula Core's auto
406+
// mode intentionally treats a selector-free invocation as an
407+
// environment materialization probe.
408+
spec.ExecutionMode = pythonhelpers.FormulaExecutionComplete
409+
}
410+
spec.Selectors = exactSelectors
411+
return nil
412+
}
413+
414+
// Non-pytest formula runners own their positional selector grammar. Core's
415+
// formula executor performs the runner-specific normalization (for example,
416+
// unittest display identities to Django dotted labels).
417+
spec.Selectors = append(spec.Selectors, req.GetFilters()...)
418+
if target := strings.TrimSpace(req.GetTarget()); target != "" {
419+
spec.Selectors = append(spec.Selectors, target)
420+
}
421+
return nil
422+
}
423+
424+
// isExactPytestSelector recognizes collection identities, not arbitrary name
425+
// expressions. This knowledge belongs in the Python runtime plugin. SWE-bench
426+
// node IDs and file targets are positional pytest operands; simple names and
427+
// boolean expressions remain filters.
428+
func isExactPytestSelector(value string) bool {
429+
value = strings.TrimSpace(value)
430+
if value == "" {
431+
return false
377432
}
378-
selectors := append([]string(nil), req.GetFilters()...)
379-
if target := req.GetTarget(); target != "" {
380-
selectors = append(selectors, target)
433+
if strings.Contains(value, "::") {
434+
return true
381435
}
382-
return selectors, nil
436+
normalized := strings.ReplaceAll(value, `\`, "/")
437+
return strings.HasSuffix(normalized, ".py") || strings.Contains(normalized, ".py/")
383438
}
384439

385440
func pythonDefaultTestScope(req *runtimev0.TestRequest) (string, []string, error) {

‎pkg/runtime/runtime_test.go‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,126 @@ commands = pytest --pyargs broadpkg {posargs}
145145
assertRuntimeTestLeftSourceClean(t, root)
146146
}
147147

148+
// TestRuntimeFormulaTargetReplacesBroadPytestDiscovery proves the public
149+
// target field is execution scope even when the project-derived formula names
150+
// broad pytest roots. The runtime owns the argv translation; callers provide
151+
// only target data. An unrelated collection crash makes accidental widening
152+
// observable through real uv + pytest.
153+
func TestRuntimeFormulaTargetReplacesBroadPytestDiscovery(t *testing.T) {
154+
if _, err := exec.LookPath("uv"); err != nil {
155+
t.Fatalf("uv is required for the production Python runtime: %v", err)
156+
}
157+
root := t.TempDir()
158+
if err := os.MkdirAll(filepath.Join(root, "broadpkg"), 0o755); err != nil {
159+
t.Fatal(err)
160+
}
161+
for path, content := range map[string]string{
162+
"tox.ini": `[testenv]
163+
commands = pytest --pyargs broadpkg {posargs}
164+
`,
165+
"broadpkg/__init__.py": "",
166+
"broadpkg/test_broken.py": "raise RuntimeError('unselected collection must not run')\n",
167+
"test_selected.py": "def test_selected_pass():\n assert 2 + 2 == 4\n",
168+
} {
169+
if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(path)), []byte(content), 0o644); err != nil {
170+
t.Fatal(err)
171+
}
172+
}
173+
174+
svc := pythonservice.New(&resources.Agent{Kind: "codefly:service", Name: "python"})
175+
svc.SourceLocation = root
176+
resp, err := pythonruntime.New(svc).Test(context.Background(), &runtimev0.TestRequest{
177+
Target: "test_selected.py::test_selected_pass",
178+
})
179+
if err != nil {
180+
t.Fatalf("Test: %v", err)
181+
}
182+
if resp.GetResult().GetState() != runtimev0.TestRunResult_PASSED || resp.GetCounts().GetTotal() != 1 || resp.GetCounts().GetPassed() != 1 {
183+
t.Fatalf("targeted result = %s counts=%+v, want only one passing case\n%s", resp.GetResult().GetState(), resp.GetCounts(), resp.GetOutput())
184+
}
185+
assertRuntimeTestLeftSourceClean(t, root)
186+
}
187+
188+
// TestRuntimeFormulaExactFilterReplacesBroadPytestDiscovery proves exact node
189+
// identities carried in the repeated filters field remain exact targets. This
190+
// is the shape used by result-driven graders that own a set of test identities.
191+
func TestRuntimeFormulaExactFilterReplacesBroadPytestDiscovery(t *testing.T) {
192+
if _, err := exec.LookPath("uv"); err != nil {
193+
t.Fatalf("uv is required for the production Python runtime: %v", err)
194+
}
195+
root := t.TempDir()
196+
if err := os.MkdirAll(filepath.Join(root, "broadpkg"), 0o755); err != nil {
197+
t.Fatal(err)
198+
}
199+
for path, content := range map[string]string{
200+
"tox.ini": `[testenv]
201+
commands = pytest --pyargs broadpkg {posargs}
202+
`,
203+
"broadpkg/__init__.py": "",
204+
"broadpkg/test_broken.py": "raise RuntimeError('unselected collection must not run')\n",
205+
"test_selected.py": "def test_selected_pass():\n assert 2 + 2 == 4\n",
206+
} {
207+
if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(path)), []byte(content), 0o644); err != nil {
208+
t.Fatal(err)
209+
}
210+
}
211+
212+
svc := pythonservice.New(&resources.Agent{Kind: "codefly:service", Name: "python"})
213+
svc.SourceLocation = root
214+
resp, err := pythonruntime.New(svc).Test(context.Background(), &runtimev0.TestRequest{
215+
Filters: []string{"test_selected.py::test_selected_pass"},
216+
})
217+
if err != nil {
218+
t.Fatalf("Test: %v", err)
219+
}
220+
if resp.GetResult().GetState() != runtimev0.TestRunResult_PASSED || resp.GetCounts().GetTotal() != 1 || resp.GetCounts().GetPassed() != 1 {
221+
t.Fatalf("filtered result = %s counts=%+v, want only one passing case\n%s", resp.GetResult().GetState(), resp.GetCounts(), resp.GetOutput())
222+
}
223+
assertRuntimeTestLeftSourceClean(t, root)
224+
}
225+
226+
// TestRuntimeFormulaNameFilterUsesPytestExpression proves ordinary name
227+
// filters retain the project-derived discovery roots and become runner-owned
228+
// pytest expressions rather than invalid positional paths.
229+
func TestRuntimeFormulaNameFilterUsesPytestExpression(t *testing.T) {
230+
if _, err := exec.LookPath("uv"); err != nil {
231+
t.Fatalf("uv is required for the production Python runtime: %v", err)
232+
}
233+
root := t.TempDir()
234+
if err := os.MkdirAll(filepath.Join(root, "broadpkg"), 0o755); err != nil {
235+
t.Fatal(err)
236+
}
237+
for path, content := range map[string]string{
238+
"tox.ini": `[testenv]
239+
commands = pytest --pyargs broadpkg {posargs}
240+
`,
241+
"broadpkg/__init__.py": "",
242+
"broadpkg/test_cases.py": `def test_selected_pass():
243+
assert 2 + 2 == 4
244+
245+
def test_unselected_fail():
246+
assert False
247+
`,
248+
} {
249+
if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(path)), []byte(content), 0o644); err != nil {
250+
t.Fatal(err)
251+
}
252+
}
253+
254+
svc := pythonservice.New(&resources.Agent{Kind: "codefly:service", Name: "python"})
255+
svc.SourceLocation = root
256+
resp, err := pythonruntime.New(svc).Test(context.Background(), &runtimev0.TestRequest{
257+
Filters: []string{"test_selected_pass"},
258+
})
259+
if err != nil {
260+
t.Fatalf("Test: %v", err)
261+
}
262+
if resp.GetResult().GetState() != runtimev0.TestRunResult_PASSED || resp.GetCounts().GetTotal() != 1 || resp.GetCounts().GetPassed() != 1 {
263+
t.Fatalf("name-filtered result = %s counts=%+v, want one selected passing case\n%s", resp.GetResult().GetState(), resp.GetCounts(), resp.GetOutput())
264+
}
265+
assertRuntimeTestLeftSourceClean(t, root)
266+
}
267+
148268
// TestRuntimeFormulaNormalizesUnittestDisplaySelection proves the production
149269
// agent boundary accepts the canonical case identity returned by unittest
150270
// (`method (module.Class)`) and routes it back through a formula that consumes

0 commit comments

Comments
 (0)