Skip to content

Commit fc7cc79

Browse files
Expose typed Python validation results without source artifacts (#2)
* feat: expose typed Python validation results * chore: pin core v0.2.34 * chore: pin core v0.2.35 * fix: declare Python agent service kind
1 parent 960b113 commit fc7cc79

6 files changed

Lines changed: 188 additions & 82 deletions

File tree

‎agent.codefly.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
publisher: codefly.dev
2+
kind: codefly:service
23
name: python
34
version: 0.0.15

‎go.mod‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ go 1.25.0
55
toolchain go1.26.4
66

77
require (
8-
github.com/codefly-dev/core v0.2.24
8+
github.com/codefly-dev/core v0.2.35
99
gopkg.in/yaml.v3 v3.0.1
1010
)
1111

@@ -82,16 +82,16 @@ require (
8282
github.com/yusufpapurcu/wmi v1.2.4 // indirect
8383
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
8484
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
85-
go.opentelemetry.io/otel v1.43.0 // indirect
86-
go.opentelemetry.io/otel/metric v1.43.0 // indirect
87-
go.opentelemetry.io/otel/trace v1.43.0 // indirect
85+
go.opentelemetry.io/otel v1.44.0 // indirect
86+
go.opentelemetry.io/otel/metric v1.44.0 // indirect
87+
go.opentelemetry.io/otel/trace v1.44.0 // indirect
8888
go.yaml.in/yaml/v3 v3.0.4 // indirect
89-
golang.org/x/crypto v0.52.0 // indirect
89+
golang.org/x/crypto v0.53.0 // indirect
9090
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
91-
golang.org/x/net v0.55.0 // indirect
92-
golang.org/x/sys v0.45.0 // indirect
93-
golang.org/x/term v0.43.0 // indirect
94-
golang.org/x/text v0.37.0 // indirect
91+
golang.org/x/net v0.56.0 // indirect
92+
golang.org/x/sys v0.46.0 // indirect
93+
golang.org/x/term v0.44.0 // indirect
94+
golang.org/x/text v0.39.0 // indirect
9595
google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529 // indirect
9696
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
9797
google.golang.org/grpc v1.80.0 // indirect

‎go.sum‎

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
3333
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
3434
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
3535
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
36-
github.com/codefly-dev/core v0.2.19 h1:J2nffMEzmr9FHkf1lh/2gNPbjJFYjpQHro3niejrv04=
37-
github.com/codefly-dev/core v0.2.19/go.mod h1:78gNY4qIlPnj08Fe8X5WwXWtqjTvU4MelJlOYTJq/5s=
36+
github.com/codefly-dev/core v0.2.35 h1:Ck0A2CKDRvCn4ANSrDXxt1yh663j6Ui35NPPgN3MP6A=
37+
github.com/codefly-dev/core v0.2.35/go.mod h1:eT7oXvFsMnKYlVz3Uyxc88snhX+yhXxbXTvfdr+GRJ4=
3838
github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ=
3939
github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg=
4040
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
@@ -205,30 +205,30 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
205205
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
206206
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo=
207207
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg=
208-
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
209-
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
210-
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
211-
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
208+
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
209+
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
210+
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
211+
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
212212
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
213213
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
214214
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
215215
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
216-
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
217-
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
216+
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
217+
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
218218
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
219219
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
220220
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
221221
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
222222
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
223223
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
224-
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
225-
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
224+
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
225+
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
226226
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
227227
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
228228
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
229229
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
230-
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
231-
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
230+
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
231+
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
232232
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
233233
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
234234
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -240,15 +240,15 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
240240
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
241241
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
242242
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
243-
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
244-
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
243+
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
244+
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
245245
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
246-
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
247-
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
246+
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
247+
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
248248
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
249249
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
250-
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
251-
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
250+
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
251+
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
252252
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
253253
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
254254
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=

‎pkg/runtime/runtime.go‎

Lines changed: 94 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package runtime
55

66
import (
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.
138116
func (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

Comments
 (0)