Skip to content

Commit 960b113

Browse files
feat: add safe Ruff source repair
1 parent 4206c1e commit 960b113

8 files changed

Lines changed: 207 additions & 37 deletions

File tree

‎agent.codefly.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
publisher: codefly.dev
22
name: python
3-
version: 0.0.14
3+
version: 0.0.15

‎go.mod‎

Lines changed: 1 addition & 1 deletion
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.23
8+
github.com/codefly-dev/core v0.2.24
99
gopkg.in/yaml.v3 v3.0.1
1010
)
1111

‎main.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/codefly-dev/core/agents"
2020
"github.com/codefly-dev/core/resources"
2121
"github.com/codefly-dev/core/shared"
22+
"github.com/codefly-dev/core/toolbox/lang"
2223

2324
pythonbuilder "github.com/codefly-dev/service-python/pkg/builder"
2425
pythoncode "github.com/codefly-dev/service-python/pkg/code"
@@ -36,11 +37,13 @@ func main() {
3637
svc := pythonservice.New(agent.Of(resources.ServiceAgent))
3738
code := pythoncode.New(svc)
3839
runtime := pythonruntime.New(svc)
40+
tooling := pythontooling.New(code, runtime)
3941
agents.Serve(agents.PluginRegistration{
4042
Agent: svc,
4143
Runtime: runtime,
4244
Builder: pythonbuilder.New(svc),
4345
Code: code,
44-
Tooling: pythontooling.New(code, runtime),
46+
Tooling: tooling,
47+
Toolbox: lang.NewToolboxFromTooling(agent.Name, agent.Version, tooling),
4548
})
4649
}

‎pkg/code/code.go‎

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@ package code
55

66
import (
77
"context"
8+
"fmt"
89
"os"
10+
"path/filepath"
11+
"strings"
912

1013
corecode "github.com/codefly-dev/core/code"
14+
basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
1115
codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0"
16+
runners "github.com/codefly-dev/core/runners/base"
1217

1318
pythonservice "github.com/codefly-dev/service-python/pkg/service"
1419
)
@@ -38,15 +43,15 @@ func New(svc *pythonservice.Service) *Code {
3843
}
3944

4045
// SourceDir returns the directory to operate on. Resolution order:
41-
// explicit Service.SourceLocation → CODEFLY_AGENT_WORKDIR → Service.Location.
46+
// explicit Service.SourceLocation → CODEFLY_AGENT_WORKDIR/source-dir → Service.Location/source-dir.
4247
func (c *Code) SourceDir() string {
4348
if c.Service.SourceLocation != "" {
4449
return c.Service.SourceLocation
4550
}
4651
if wd := os.Getenv("CODEFLY_AGENT_WORKDIR"); wd != "" {
47-
return wd
52+
return filepath.Join(wd, c.Service.Settings.PythonSourceDir())
4853
}
49-
return c.Service.Location
54+
return filepath.Join(c.Service.Location, c.Service.Settings.PythonSourceDir())
5055
}
5156

5257
// EnsureInit lazily swaps in a PythonCodeServer pointed at the resolved
@@ -55,23 +60,68 @@ func (c *Code) SourceDir() string {
5560
func (c *Code) EnsureInit() {
5661
if !c.initialized {
5762
c.PythonCodeServer = corecode.NewPythonCodeServer(c.SourceDir(), nil)
63+
c.SetSourceFixer(c.fixPython)
5864
c.initialized = true
5965
}
6066
}
6167

62-
// ── Lazy-init RPC wrappers ───────────────────────────────
63-
64-
func (c *Code) GetProjectInfo(ctx context.Context, req *codev0.GetProjectInfoRequest) (*codev0.GetProjectInfoResponse, error) {
68+
func (c *Code) Execute(ctx context.Context, req *codev0.CodeRequest) (*codev0.CodeResponse, error) {
6569
c.EnsureInit()
66-
return c.PythonCodeServer.GetProjectInfo(ctx, req)
70+
return c.PythonCodeServer.Execute(ctx, req)
6771
}
6872

69-
func (c *Code) ListDependencies(ctx context.Context, req *codev0.ListDependenciesRequest) (*codev0.ListDependenciesResponse, error) {
70-
c.EnsureInit()
71-
return c.PythonCodeServer.ListDependencies(ctx, req)
73+
// fixPython applies only project-configured Ruff safe fixes, then Ruff's
74+
// formatter. Both stages operate on stdin and return source on stdout, so the
75+
// core Code server remains the sole writer and can honor VFS/dry-run semantics.
76+
func (c *Code) fixPython(ctx context.Context, input corecode.FixInput) (corecode.FixResult, error) {
77+
checkArgs := []string{"check", "--fix-only"}
78+
if input.Mode == basev0.FixMode_FIX_MODE_AGGRESSIVE {
79+
checkArgs = append(checkArgs, "--unsafe-fixes")
80+
}
81+
checkArgs = append(checkArgs, "--stdin-filename", input.Path, "-")
82+
83+
// Fix is an authoring primitive, not environment initialization. Invoke a
84+
// provisioned Ruff directly so a dry run cannot create .venv/uv.lock or
85+
// download the project's dependency graph. Factory Nix shells include Ruff,
86+
// the Docker runtime exposes /venv/bin on PATH, and native users may supply
87+
// either a project-local .venv binary or Ruff on the host PATH.
88+
ruff := c.ruffCommand()
89+
checked, checkLogBytes, err := runners.RunInput(ctx, c.runnerEnvironment(ctx), c.SourceDir(), input.Content, ruff, checkArgs...)
90+
checkLog := string(checkLogBytes)
91+
if err != nil {
92+
return corecode.FixResult{}, fmt.Errorf("ruff check --fix-only (Ruff must already be provisioned): %w: %s", err, strings.TrimSpace(checkLog))
93+
}
94+
formatted, formatLogBytes, err := runners.RunInput(ctx, c.runnerEnvironment(ctx), c.SourceDir(), checked, ruff, "format", "--stdin-filename", input.Path, "-")
95+
formatLog := string(formatLogBytes)
96+
if err != nil {
97+
return corecode.FixResult{}, fmt.Errorf("ruff format: %w: %s", err, strings.TrimSpace(formatLog))
98+
}
99+
output := strings.TrimSpace(strings.Join([]string{checkLog, formatLog}, "\n"))
100+
return corecode.FixResult{
101+
Content: formatted,
102+
Actions: []string{"ruff check --fix-only", "ruff format"},
103+
Output: output,
104+
}, nil
72105
}
73106

74-
func (c *Code) Execute(ctx context.Context, req *codev0.CodeRequest) (*codev0.CodeResponse, error) {
75-
c.EnsureInit()
76-
return c.PythonCodeServer.Execute(ctx, req)
107+
func (c *Code) ruffCommand() string {
108+
if c.Service.ActiveEnv == nil {
109+
candidate := filepath.Join(c.SourceDir(), ".venv", "bin", "ruff")
110+
if info, err := os.Stat(candidate); err == nil && info.Mode().IsRegular() && info.Mode().Perm()&0o111 != 0 {
111+
return candidate
112+
}
113+
}
114+
return "ruff"
115+
}
116+
117+
func (c *Code) runnerEnvironment(ctx context.Context) runners.RunnerEnvironment {
118+
env := c.Service.ActiveEnv
119+
if env != nil {
120+
return env
121+
}
122+
var runtimeContext *basev0.RuntimeContext
123+
if c.Service.Base != nil && c.Service.Base.Runtime != nil {
124+
runtimeContext = c.Service.Base.Runtime.RuntimeContext
125+
}
126+
return runners.ResolveStandaloneEnvironment(ctx, c.SourceDir(), runtimeContext)
77127
}

‎pkg/code/fix_test.go‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package code_test
2+
3+
import (
4+
"context"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
11+
codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0"
12+
"github.com/codefly-dev/core/resources"
13+
pythoncode "github.com/codefly-dev/service-python/pkg/code"
14+
pythonservice "github.com/codefly-dev/service-python/pkg/service"
15+
)
16+
17+
func TestFixUsesRuffAndHonorsDryRun(t *testing.T) {
18+
if _, err := exec.LookPath("ruff"); err != nil {
19+
t.Skip("ruff is not installed")
20+
}
21+
dir := t.TempDir()
22+
original := "import os\n\ndef answer( ):\n return 42\n"
23+
if err := os.WriteFile(filepath.Join(dir, "sample.py"), []byte(original), 0o644); err != nil {
24+
t.Fatal(err)
25+
}
26+
svc := pythonservice.New(&resources.Agent{Kind: "codefly:service", Name: "python"})
27+
svc.SourceLocation = dir
28+
server := pythoncode.New(svc)
29+
response, err := server.Execute(context.Background(), &codev0.CodeRequest{Operation: &codev0.CodeRequest_Fix{Fix: &codev0.FixRequest{
30+
File: "sample.py", DryRun: true,
31+
}}})
32+
if err != nil {
33+
t.Fatal(err)
34+
}
35+
fix := response.GetFix()
36+
if !fix.GetSuccess() || !fix.GetChanged() || fix.GetWrote() {
37+
t.Fatalf("dry-run fix = %+v failure=%+v", fix, response.GetFailure())
38+
}
39+
if strings.Contains(fix.GetContent(), "import os") || !strings.Contains(fix.GetContent(), "def answer():") {
40+
t.Fatalf("Ruff pipeline did not lint+format:\n%s", fix.GetContent())
41+
}
42+
written, err := os.ReadFile(filepath.Join(dir, "sample.py"))
43+
if err != nil || string(written) != original {
44+
t.Fatalf("dry-run changed source: err=%v content=%q", err, written)
45+
}
46+
for _, unexpected := range []string{".venv", "uv.lock"} {
47+
if _, err := os.Stat(filepath.Join(dir, unexpected)); !os.IsNotExist(err) {
48+
t.Fatalf("dry-run materialized %s: %v", unexpected, err)
49+
}
50+
}
51+
}
52+
53+
func TestApplyEditRunsRuffSafeFixerByDefault(t *testing.T) {
54+
if _, err := exec.LookPath("ruff"); err != nil {
55+
t.Skip("ruff is not installed")
56+
}
57+
dir := t.TempDir()
58+
original := "import os\n\ndef answer( ):\n return 42\n"
59+
if err := os.WriteFile(filepath.Join(dir, "sample.py"), []byte(original), 0o644); err != nil {
60+
t.Fatal(err)
61+
}
62+
svc := pythonservice.New(&resources.Agent{Kind: "codefly:service", Name: "python"})
63+
svc.SourceLocation = dir
64+
server := pythoncode.New(svc)
65+
response, err := server.Execute(context.Background(), &codev0.CodeRequest{Operation: &codev0.CodeRequest_ApplyEdit{ApplyEdit: &codev0.ApplyEditRequest{
66+
File: "sample.py", Find: "return 42", Replace: "return( 43 )",
67+
}}})
68+
if err != nil {
69+
t.Fatal(err)
70+
}
71+
edit := response.GetApplyEdit()
72+
if !edit.GetSuccess() || !edit.GetChanged() || !edit.GetWrote() {
73+
t.Fatalf("ApplyEdit = %+v failure=%+v", edit, response.GetFailure())
74+
}
75+
if strings.Contains(edit.GetContent(), "import os") || !strings.Contains(edit.GetContent(), "def answer():\n return 43") {
76+
t.Fatalf("Ruff was not composed into ApplyEdit:\n%s", edit.GetContent())
77+
}
78+
written, err := os.ReadFile(filepath.Join(dir, "sample.py"))
79+
if err != nil || string(written) != edit.GetContent() {
80+
t.Fatalf("ApplyEdit did not commit returned content: err=%v content=%q", err, written)
81+
}
82+
}

‎pkg/runtime/runtime.go‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,18 @@ func (s *Runtime) Load(ctx context.Context, req *runtimev0.LoadRequest) (*runtim
5959

6060
response, err := s.Runtime.LoadService(ctx, req, services.RuntimeLoad{Settings: s.Service.Settings})
6161

62-
// Python convention: source is the service directory itself.
63-
s.Service.SourceLocation = s.Location
62+
// Prefer the conventional code/ source tree while retaining root-level
63+
// pyproject checkouts. Code, Runtime, and arbitrary-source adapters then
64+
// share one source-root contract.
65+
root := s.Location
6466
if wd := os.Getenv("CODEFLY_AGENT_WORKDIR"); wd != "" {
65-
s.Service.SourceLocation = wd
67+
root = wd
68+
}
69+
s.Service.SourceLocation = filepath.Join(root, s.Service.Settings.PythonSourceDir())
70+
if _, statErr := os.Stat(filepath.Join(s.Service.SourceLocation, "pyproject.toml")); statErr != nil {
71+
if _, rootErr := os.Stat(filepath.Join(root, "pyproject.toml")); rootErr == nil {
72+
s.Service.SourceLocation = root
73+
}
6674
}
6775

6876
if response != nil && response.Status != nil {
@@ -208,16 +216,21 @@ func (s *Runtime) Test(ctx context.Context, req *runtimev0.TestRequest) (*runtim
208216
}
209217

210218
// Lint runs ruff via the core python helper.
211-
func (s *Runtime) Lint(ctx context.Context, _ *runtimev0.LintRequest) (*runtimev0.LintResponse, error) {
219+
func (s *Runtime) Lint(ctx context.Context, req *runtimev0.LintRequest) (*runtimev0.LintResponse, error) {
212220
defer s.Wool.Catch()
221+
if req == nil {
222+
req = &runtimev0.LintRequest{}
223+
}
213224

214-
output, err := pythonhelpers.RunPythonLint(ctx, s.Service.SourceLocation)
225+
output, err := pythonhelpers.RunPythonLint(ctx, s.Service.SourceLocation, req.GetTarget())
226+
compressed := llmout.Compress("ruff", []string{"check"}, output)
215227

216228
return &runtimev0.LintResponse{
217229
Status: &runtimev0.LintStatus{
218230
State: boolToLintState(err == nil),
219-
Message: llmout.Compress("ruff", []string{"check"}, output),
231+
Message: compressed,
220232
},
233+
Output: compressed,
221234
}, nil
222235
}
223236

‎pkg/service/service.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ import (
2222
// }
2323
type Settings struct {
2424
PythonVersion string `yaml:"python-version"`
25+
SourceDir string `yaml:"source-dir"`
26+
}
27+
28+
func (s *Settings) PythonSourceDir() string {
29+
if s.SourceDir != "" {
30+
return s.SourceDir
31+
}
32+
return "code"
2533
}
2634

2735
// Service carries the shared state used by Runtime, Code, Tooling, Builder.

0 commit comments

Comments
 (0)