@@ -5,6 +5,8 @@ package runtime
55
66import (
77 "context"
8+ "errors"
9+ "fmt"
810 "os"
911 "path/filepath"
1012 "sync"
@@ -14,15 +16,15 @@ import (
1416 runtimev0 "github.com/codefly-dev/core/generated/go/codefly/services/runtime/v0"
1517 "github.com/codefly-dev/core/llmout"
1618 "github.com/codefly-dev/core/resources"
17- runners "github.com/codefly-dev/core/runners/base"
1819 pythonhelpers "github.com/codefly-dev/core/runners/python"
20+ selectioncontract "github.com/codefly-dev/core/runners/testselection"
1921 "github.com/codefly-dev/core/wool"
2022
2123 pythonservice "github.com/codefly-dev/service-python/pkg/service"
2224)
2325
2426// Runtime is the generic Python runtime server. Embedded by specializations
25- // to inherit uv-based dep sync, pytest, ruff lint, the no-op Build, and
27+ // to inherit externally provisioned pytest, ruff lint, syntax Build, and
2628// the persistent Python REPL commands defined in commands.go.
2729//
2830// *pythonservice.Service is embedded (not held as a named field) so that
@@ -79,36 +81,13 @@ func (s *Runtime) Load(ctx context.Context, req *runtimev0.LoadRequest) (*runtim
7981 return response , err
8082}
8183
82- // Init runs `uv sync` to materialize the venv. Non-fatal on failure.
83- // Specializations may override to add their own init (e.g. collecting
84- // network mappings, wiring env vars) — they should still call this first.
85- func (s * Runtime ) Init (ctx context.Context , _ * runtimev0.InitRequest ) (* runtimev0.InitResponse , error ) {
84+ // Init deliberately does not materialize dependencies in the source checkout.
85+ // Test formulas provision their environment through uv's external cache, and
86+ // specializations may create a runner environment without writing .venv or
87+ // uv.lock into the project. This keeps initialization safe for read-only
88+ // control-plane operations such as source inspection and linting.
89+ func (s * Runtime ) Init (_ context.Context , _ * runtimev0.InitRequest ) (* runtimev0.InitResponse , error ) {
8690 defer s .Wool .Catch ()
87-
88- // Route `uv sync` through the plugin's active RunnerEnvironment so
89- // Docker/Nix/native mode stays consistent. Fallback to fresh native
90- // when ActiveEnv is nil (generic Runtime.Init runs before a
91- // specialization has called CreateRunnerEnvironment — rare, but the
92- // fallback keeps the generic path usable standalone).
93- env := s .Service .ActiveEnv
94- if env == nil {
95- native , envErr := runners .NewNativeEnvironment (ctx , s .Service .SourceLocation )
96- if envErr != nil {
97- s .Wool .Warn ("uv sync skipped — cannot create runner environment" , nil )
98- return s .Runtime .InitResponse ()
99- }
100- env = native
101- }
102- proc , procErr := env .NewProcess ("uv" , "sync" )
103- if procErr != nil {
104- s .Wool .Warn ("uv sync skipped — cannot create process" , nil )
105- return s .Runtime .InitResponse ()
106- }
107- proc .WithOutput (s .Logger )
108- if err := proc .Run (ctx ); err != nil {
109- s .Wool .Warn ("uv sync failed (non-fatal)" , nil )
110- }
111-
11291 return s .Runtime .InitResponse ()
11392}
11493
@@ -129,14 +108,16 @@ func (s *Runtime) Destroy(_ context.Context, _ *runtimev0.DestroyRequest) (*runt
129108}
130109
131110// Test runs pytest via the core python helper and returns the structured
132- // summary. Streams per-test events to the logger as they arrive (TUI
133- // shows live progress) and persists raw output to <SourceLocation>/.cache/
134- // last-test.txt for post-mortem.
111+ // summary. Streams per-test events to the logger as they arrive (TUI shows live
112+ // progress). Runtime evidence stays outside the project checkout.
135113//
136114// Specializations should NOT override this unless their layer has extra
137115// setup (e.g. fixtures) beyond what Init already did.
138116func (s * Runtime ) Test (ctx context.Context , req * runtimev0.TestRequest ) (* runtimev0.TestResponse , error ) {
139117 defer s .Wool .Catch ()
118+ if err := selectioncontract .ValidateRequest (req ); err != nil {
119+ return nil , fmt .Errorf ("python runtime: invalid test request: %w" , err )
120+ }
140121
141122 s .Wool .Info ("running python tests" ,
142123 wool .Field ("target" , req .Target ),
@@ -151,23 +132,34 @@ func (s *Runtime) Test(ctx context.Context, req *runtimev0.TestRequest) (*runtim
151132 // instead of detecting a runner. The brain stays framework-blind; this
152133 // plugin is the ONLY place the generic provisioning map becomes uv flags.
153134 if cmd , output , env , prov , ok := resolveTestFormula (req , s .Base .Service , s .Service .SourceLocation ); ok {
154- selectors := append ([]string {}, req .Filters ... )
155- if req .Target != "" {
156- selectors = append (selectors , req .Target )
135+ fspec := pythonhelpers .SpecFromFormula (cmd , output , env , prov , nil )
136+ selectors , selectorErr := pythonTestRequestSelectors (req , fspec .Command , fspec .Cwd )
137+ if selectorErr != nil {
138+ return nil , fmt .Errorf ("python runtime selection: %w" , selectorErr )
157139 }
158- fspec := pythonhelpers . SpecFromFormula ( cmd , output , env , prov , selectors )
140+ fspec . Selectors = selectors
159141 s .Wool .Info ("running test formula via uv" ,
160142 wool .Field ("command" , fspec .Command ), wool .Field ("output" , fspec .Output ))
161143 fStart := time .Now ()
162144 fRun , fErr := pythonhelpers .RunFormulaStructured (ctx , s .Service .SourceLocation , fspec )
163145 evidence := pythonhelpers .RuntimeEvidenceForFormula (s .Service .SourceLocation , cmd , output , env , prov , formulaDerivedFromProject (req , s .Base .Service ))
164146 if fRun == nil {
165- return s .testErrorWithEvidence (fErr , evidence )
147+ resp , rpcErr := s .testErrorWithEvidence (fErr , evidence )
148+ ensureTestRun (resp , "formula" , req .GetSuite ())
149+ return acknowledgeTestSelection (req , resp , rpcErr )
166150 }
167151 s .Wool .Forwardf ("Tests: %s" , fRun .LegacyTestSummary ().SummaryLine ())
168152 resp := fRun .ToProtoResponse ("formula" , req .Suite , time .Since (fStart ))
169153 appendTestResponseEvidence (resp , evidence )
170- return resp , fErr
154+ // A normal failing test process exits non-zero. Its structured response
155+ // is the operation result, not a gRPC transport error; returning fErr
156+ // would make gRPC discard the response and its selection acknowledgement.
157+ return acknowledgeTestSelection (req , resp , nil )
158+ }
159+
160+ target , filters , selectorErr := pythonDefaultTestScope (req )
161+ if selectorErr != nil {
162+ return nil , fmt .Errorf ("python runtime selection: %w" , selectorErr )
171163 }
172164
173165 opts := pythonhelpers.TestOptions {
@@ -183,12 +175,9 @@ func (s *Runtime) Test(ctx context.Context, req *runtimev0.TestRequest) (*runtim
183175 s .Wool .Forwardf ("SKIP %s" , ev .Test )
184176 }
185177 },
186- // Persist raw output for post-mortem. Best-effort.
187- CacheDir : filepath .Join (s .Service .SourceLocation , ".cache" ),
188-
189178 // Forward CLI flags to pytest.
190- Target : req . Target ,
191- Filters : req . Filters ,
179+ Target : target ,
180+ Filters : filters ,
192181 Verbose : req .Verbose ,
193182 VerboseSet : true ,
194183 Timeout : req .Timeout ,
@@ -208,10 +197,58 @@ func (s *Runtime) Test(ctx context.Context, req *runtimev0.TestRequest) (*runtim
208197 }
209198
210199 if run == nil {
211- return s .testErrorWithEvidence (runErr , pythonhelpers .RuntimeEvidence (s .Service .SourceLocation ))
200+ resp , rpcErr := s .testErrorWithEvidence (runErr , pythonhelpers .RuntimeEvidence (s .Service .SourceLocation ))
201+ ensureTestRun (resp , "pytest" , req .GetSuite ())
202+ return acknowledgeTestSelection (req , resp , rpcErr )
203+ }
204+ if runErr != nil && run .LegacyTestSummary ().Run == 0 {
205+ resp , rpcErr := s .testErrorWithEvidence (runErr , pythonhelpers .RuntimeEvidence (s .Service .SourceLocation ))
206+ ensureTestRun (resp , "pytest" , req .GetSuite ())
207+ return acknowledgeTestSelection (req , resp , rpcErr )
212208 }
213209 resp := run .ToProtoResponse ("pytest" , req .Suite , duration )
214210 appendTestResponseEvidence (resp , pythonhelpers .RuntimeEvidence (s .Service .SourceLocation ))
211+ return acknowledgeTestSelection (req , resp , nil )
212+ }
213+
214+ // pythonTestRequestSelectors translates authoritative typed scope inside the
215+ // Python plugin. Legacy target/filter requests remain supported for interactive
216+ // broad runs, but never participate in the typed acknowledgement contract.
217+ func pythonTestRequestSelectors (req * runtimev0.TestRequest , command []string , cwd string ) ([]string , error ) {
218+ if req .GetSelection () != nil {
219+ return pythonhelpers .RenderTestSelection (req .GetSelection (), command , cwd )
220+ }
221+ selectors := append ([]string (nil ), req .GetFilters ()... )
222+ if target := req .GetTarget (); target != "" {
223+ selectors = append (selectors , target )
224+ }
225+ return selectors , nil
226+ }
227+
228+ func pythonDefaultTestScope (req * runtimev0.TestRequest ) (string , []string , error ) {
229+ if req .GetSelection () == nil {
230+ return req .GetTarget (), append ([]string (nil ), req .GetFilters ()... ), nil
231+ }
232+ selectors , err := pythonhelpers .RenderTestSelection (req .GetSelection (), []string {"pytest" }, "" )
233+ if err != nil {
234+ return "" , nil , err
235+ }
236+ if len (selectors ) != 1 {
237+ return "" , nil , fmt .Errorf ("typed Python selection rendered %d targets, want exactly one" , len (selectors ))
238+ }
239+ return selectors [0 ], nil , nil
240+ }
241+
242+ func ensureTestRun (resp * runtimev0.TestResponse , runner , suite string ) {
243+ if resp != nil && resp .Run == nil {
244+ resp .Run = & runtimev0.TestRun {Runner : runner , SuiteName : suite }
245+ }
246+ }
247+
248+ func acknowledgeTestSelection (req * runtimev0.TestRequest , resp * runtimev0.TestResponse , runErr error ) (* runtimev0.TestResponse , error ) {
249+ if ackErr := selectioncontract .Acknowledge (req , resp ); ackErr != nil {
250+ return resp , errors .Join (runErr , fmt .Errorf ("acknowledge typed test selection: %w" , ackErr ))
251+ }
215252 return resp , runErr
216253}
217254
@@ -234,10 +271,17 @@ func (s *Runtime) Lint(ctx context.Context, req *runtimev0.LintRequest) (*runtim
234271 }, nil
235272}
236273
237- // Build is a no-op for Python — uv sync handles dep resolution.
238- // Specializations producing artifacts (containers, wheels) override.
239- func (s * Runtime ) Build (_ context.Context , _ * runtimev0.BuildRequest ) (* runtimev0.BuildResponse , error ) {
240- return & runtimev0.BuildResponse {}, nil
274+ // Build is Python's read-only compile gate. Packaging remains separate, but a
275+ // Build RPC must never claim success for syntactically invalid source.
276+ func (s * Runtime ) Build (ctx context.Context , req * runtimev0.BuildRequest ) (* runtimev0.BuildResponse , error ) {
277+ if req == nil {
278+ req = & runtimev0.BuildRequest {}
279+ }
280+ output , err := pythonhelpers .RunPythonBuild (ctx , s .Service .SourceLocation , req .GetTarget ())
281+ if err != nil {
282+ return s .Runtime .BuildErrorf (err , "python compile failed:\n %s" , output )
283+ }
284+ return s .Runtime .BuildResponse (output )
241285}
242286
243287// resolveTestFormula picks the test formula to run: a per-call
0 commit comments