Skip to content

Commit f5bc134

Browse files
committed
feat(protogen): add csharp and python outputs
Signed-off-by: Christian Stewart <christian@aperture.us>
1 parent 99f51b3 commit f5bc134

14 files changed

Lines changed: 349 additions & 90 deletions

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,9 @@ Example:
207207
### `aptre.languages`
208208

209209
`languages` selects which output languages to generate. Supported values are
210-
`go`, `ts`, `cpp`, and `rust`. Omit it to keep the default behavior: generate
211-
Go, C++, and Rust when the project has a `go.mod`, plus TypeScript when the
212-
project has a `package.json`.
210+
`go`, `ts`, `cpp`, `rust`, `csharp`, and `python`. C# and Python are opt-in.
211+
Omit `languages` to preserve the existing default: generate Go, C++, and Rust
212+
when the project has a `go.mod`, plus TypeScript when it has a `package.json`.
213213

214214
The CLI flag takes precedence over `package.json`:
215215

cmd/aptre/cmd-generate.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ var generateCmd = &cli.Command{
5151
&cli.StringSliceFlag{
5252
Name: "language",
5353
Aliases: []string{"l"},
54-
Usage: "Output language to generate: go, ts, cpp, rust (can be specified multiple times)",
54+
Usage: "Output language to generate: go, ts, cpp, rust, csharp, python (can be specified multiple times)",
5555
},
5656
&cli.StringSliceFlag{
5757
Name: "rpc",

cmd/aptre/cmd-generate_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package main
22

33
import (
4+
"bytes"
45
"os"
56
"os/exec"
67
"path/filepath"
78
"runtime"
9+
"strings"
810
"testing"
911

1012
"github.com/aperturerobotics/common/protogen"
@@ -140,6 +142,99 @@ message Scratch {
140142
}
141143
}
142144

145+
func TestGenerateCSharpAndPython(t *testing.T) {
146+
t.Helper()
147+
148+
projectDir := t.TempDir()
149+
goMod := []byte("module example.com/scratch\n\ngo 1.25.0\n")
150+
if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), goMod, 0o644); err != nil {
151+
t.Fatalf("write go.mod: %v", err)
152+
}
153+
protoFile := []byte(`syntax = "proto3";
154+
package scratch;
155+
156+
message Scratch {
157+
string value = 1;
158+
}
159+
`)
160+
if err := os.WriteFile(filepath.Join(projectDir, "scratch.proto"), protoFile, 0o644); err != nil {
161+
t.Fatalf("write scratch.proto: %v", err)
162+
}
163+
runTestCommand(t, projectDir, "git", "init")
164+
runTestCommand(t, projectDir, "git", "add", "scratch.proto")
165+
166+
cfg := protogen.NewConfig()
167+
cfg.ProjectDir = projectDir
168+
cfg.Languages = []string{"csharp", "python"}
169+
170+
gen, err := protogen.NewGenerator(cfg)
171+
if err != nil {
172+
t.Fatalf("new generator: %v", err)
173+
}
174+
if err := gen.Generate(t.Context()); err != nil {
175+
t.Fatalf("generate: %v", err)
176+
}
177+
178+
csharpPath := filepath.Join(projectDir, "Scratch.cs")
179+
pythonPath := filepath.Join(projectDir, "scratch_pb2.py")
180+
csharp, err := os.ReadFile(csharpPath)
181+
if err != nil {
182+
t.Fatalf("read C# output: %v", err)
183+
}
184+
python, err := os.ReadFile(pythonPath)
185+
if err != nil {
186+
var files []string
187+
_ = filepath.WalkDir(projectDir, func(path string, entry os.DirEntry, walkErr error) error {
188+
if walkErr == nil && !entry.IsDir() {
189+
files = append(files, path)
190+
}
191+
return nil
192+
})
193+
t.Fatalf("read Python output: %v; files: %v", err, files)
194+
}
195+
if !bytes.Contains(csharp, []byte("class Scratch")) {
196+
t.Fatal("C# output does not contain Scratch")
197+
}
198+
if !bytes.Contains(python, []byte("Scratch")) {
199+
t.Fatal("Python output does not contain Scratch")
200+
}
201+
202+
var stdout bytes.Buffer
203+
gen, err = protogen.NewGenerator(cfg)
204+
if err != nil {
205+
t.Fatalf("new cached generator: %v", err)
206+
}
207+
gen.Verbose = true
208+
gen.Stdout = &stdout
209+
if err := gen.Generate(t.Context()); err != nil {
210+
t.Fatalf("cached generate: %v", err)
211+
}
212+
if !strings.Contains(stdout.String(), "Skipping . (up to date)") {
213+
t.Fatalf("expected second-run cache reuse, got %q", stdout.String())
214+
}
215+
if got, err := os.ReadFile(csharpPath); err != nil || !bytes.Equal(got, csharp) {
216+
t.Fatalf("C# second-run output changed: %v", err)
217+
}
218+
if got, err := os.ReadFile(pythonPath); err != nil || !bytes.Equal(got, python) {
219+
t.Fatalf("Python second-run output changed: %v", err)
220+
}
221+
222+
cfg.Languages = []string{"csharp"}
223+
gen, err = protogen.NewGenerator(cfg)
224+
if err != nil {
225+
t.Fatalf("new C# generator: %v", err)
226+
}
227+
if err := gen.Generate(t.Context()); err != nil {
228+
t.Fatalf("C# generate: %v", err)
229+
}
230+
if _, err := os.Stat(pythonPath); !os.IsNotExist(err) {
231+
t.Fatalf("expected stale Python output removal, got %v", err)
232+
}
233+
if got, err := os.ReadFile(csharpPath); err != nil || !bytes.Equal(got, csharp) {
234+
t.Fatalf("C# output changed after language invalidation: %v", err)
235+
}
236+
}
237+
143238
func repoRoot(t *testing.T) string {
144239
t.Helper()
145240

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ require (
66
github.com/aperturerobotics/abseil-cpp v0.0.0-20260131110040-4bb56e2f9017 // aperture-2
77
github.com/aperturerobotics/cli v1.1.0
88
github.com/aperturerobotics/go-protoc-gen-prost v0.0.0-20260329113538-218ccd8f20e0 // master
9-
github.com/aperturerobotics/go-protoc-wasi v0.0.0-20260329113540-600516012db3 // master
9+
github.com/aperturerobotics/go-protoc-wasi v0.0.0-20260712054757-d8078c296c17 // master
1010
github.com/aperturerobotics/go-websocket v1.8.15-0.20260329113544-74dbfb8f11c6 // indirect
1111
github.com/aperturerobotics/json-iterator-lite v1.1.0 // indirect
1212
github.com/aperturerobotics/protobuf v0.0.0-20260203024654-8201686529c4 // wasi

go.sum

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,14 @@ github.com/aperturerobotics/cli v1.1.0 h1:7a+YRC+EY3npAnTzhHV5gLCiw91KS0Ts3XwLIL
44
github.com/aperturerobotics/cli v1.1.0/go.mod h1:M7BFP9wow5ytTzMyJQOOO991fGfsUqdTI7gGEsHfTQ8=
55
github.com/aperturerobotics/go-protoc-gen-prost v0.0.0-20260329113538-218ccd8f20e0 h1:6/3RSSlPEQ6LeidslB1ZCJkxW+MnfYDkvdWMDklDXw4=
66
github.com/aperturerobotics/go-protoc-gen-prost v0.0.0-20260329113538-218ccd8f20e0/go.mod h1:OBb/beWmr/pDIZAUfi86j/4tBh2v5ctTxKMqSnh9c/4=
7-
github.com/aperturerobotics/go-protoc-wasi v0.0.0-20260329113540-600516012db3 h1:lp+V8RYcBwTX1p81swkpZn5fhw1wn2xLorzETIxRyZQ=
8-
github.com/aperturerobotics/go-protoc-wasi v0.0.0-20260329113540-600516012db3/go.mod h1:vEq8i7EKb32+KXGtIEZjjhNns+BdsL2dUMw4uhy3578=
7+
github.com/aperturerobotics/go-protoc-wasi v0.0.0-20260712054757-d8078c296c17 h1:WT1YmZjJf8fUF1zKbeNNKXFA+t8Wtbr8AVMAmdKcrUU=
8+
github.com/aperturerobotics/go-protoc-wasi v0.0.0-20260712054757-d8078c296c17/go.mod h1:vEq8i7EKb32+KXGtIEZjjhNns+BdsL2dUMw4uhy3578=
99
github.com/aperturerobotics/go-websocket v1.8.15-0.20260329113544-74dbfb8f11c6 h1:Utc1F7jdCc6/HrwwIikJFXt/hXxkWIWETLp/CsG6Gl0=
1010
github.com/aperturerobotics/go-websocket v1.8.15-0.20260329113544-74dbfb8f11c6/go.mod h1:9KnSGuqxSXbdB/Oi0I6vvfPLkclfJwMGAQaDDGVgGow=
11-
github.com/aperturerobotics/json-iterator-lite v1.0.1-0.20251104042408-0c9eb8a3f726 h1:4B1F0DzuqPzb6WqgCjWaqDD7JU9RDsevQG5OP0DFBgs=
12-
github.com/aperturerobotics/json-iterator-lite v1.0.1-0.20251104042408-0c9eb8a3f726/go.mod h1:SvGGBv3OVxUyqO0ZxA/nvs6z3cg7NIbZ64TnbV2OISo=
1311
github.com/aperturerobotics/json-iterator-lite v1.1.0 h1:ZLLqHhHTKYlmCAP873ras2e/yVU/THtwC+ji3tXLMMg=
1412
github.com/aperturerobotics/json-iterator-lite v1.1.0/go.mod h1:SvGGBv3OVxUyqO0ZxA/nvs6z3cg7NIbZ64TnbV2OISo=
1513
github.com/aperturerobotics/protobuf v0.0.0-20260203024654-8201686529c4 h1:4Dy3BAHh2kgVdHAqtlwcFsgY0kAwUe2m3rfFcaGwGQg=
1614
github.com/aperturerobotics/protobuf v0.0.0-20260203024654-8201686529c4/go.mod h1:tMgO7y6SJo/d9ZcvrpNqIQtdYT9de+QmYaHOZ4KnhOg=
17-
github.com/aperturerobotics/protobuf-go-lite v0.14.0 h1:6YhovtoUZtXgXLHZ2VV2GCYUzFfi8UN6172Vl2flNlE=
18-
github.com/aperturerobotics/protobuf-go-lite v0.14.0/go.mod h1:lGH3s5ArCTXKI4wJdlNpaybUtwSjfAG0vdWjxOfMcF8=
1915
github.com/aperturerobotics/protobuf-go-lite v0.15.0 h1:Ke9pkt5zO5iQf2q7iAYsXHlCDhGeQH0cvhCTIiiWUIg=
2016
github.com/aperturerobotics/protobuf-go-lite v0.15.0/go.mod h1:3Ay/E7iaw2KWLirK3+dDdNJZHK0hu8Y1/kKeYeUa+8s=
2117
github.com/aperturerobotics/starpc v0.49.18 h1:ZDxYN6Dncm+e7WzVvvF/9ja3Bh34ETgZyciL+BIvqbU=

protogen/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ type Config struct {
4141
// ExtraArgs contains any additional protoc arguments.
4242
ExtraArgs []string
4343
// Languages is the opt-in protobuf output language filter.
44-
// Empty enables all languages.
44+
// Empty preserves the Go, TypeScript, C++, and Rust defaults.
4545
Languages []string
4646
// RPCLibraries is the opt-in RPC stub generator filter.
4747
// Empty enables the default RPC library set.

protogen/config_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,14 +241,19 @@ func TestConfigGetLanguagesDefaultAll(t *testing.T) {
241241
t.Fatalf("expected language %q to be enabled by default", lang)
242242
}
243243
}
244+
for _, lang := range []Language{LanguageCSharp, LanguagePython} {
245+
if langs.Has(lang) {
246+
t.Fatalf("expected opt-in language %q to be disabled by default", lang)
247+
}
248+
}
244249
}
245250

246251
func TestConfigGetLanguagesUnknown(t *testing.T) {
247252
t.Helper()
248253

249254
cfg := NewConfig()
250255
cfg.ProjectDir = t.TempDir()
251-
cfg.Languages = []string{"go", "python"}
256+
cfg.Languages = []string{"go", "kotlin"}
252257

253258
if _, err := cfg.GetLanguages(); err == nil {
254259
t.Fatal("expected unknown language error")

protogen/discovery.go

Lines changed: 78 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -131,63 +131,102 @@ func GetGeneratedFiles(protoFile, projectDir, modulePath string, hasGo, hasTS bo
131131
return files
132132
}
133133

134-
// FindGeneratedFilesForProto finds actual generated files for a proto file using glob.
135-
func FindGeneratedFilesForProto(protoFile, projectDir, vendorDir, modulePath string) ([]string, error) {
134+
// FindGeneratedFilesForProto finds actual enabled outputs for a proto file.
135+
func FindGeneratedFilesForProto(protoFile, projectDir, vendorDir, modulePath string, langs Languages) ([]string, error) {
136136
protoDir := filepath.Dir(protoFile)
137137
baseName := strings.TrimSuffix(filepath.Base(protoFile), ".proto")
138138

139-
// Search in local proto directory first (preferred location)
140-
localPattern := filepath.Join(projectDir, protoDir, baseName+"*.pb.*")
141-
localMatches, err := filepath.Glob(localPattern)
142-
if err != nil {
143-
return nil, err
139+
var csharpBase strings.Builder
140+
capNext := true
141+
for i := range len(baseName) {
142+
c := baseName[i]
143+
switch {
144+
case c >= 'a' && c <= 'z':
145+
if capNext {
146+
c -= 'a' - 'A'
147+
}
148+
csharpBase.WriteByte(c)
149+
capNext = false
150+
case c >= 'A' && c <= 'Z':
151+
csharpBase.WriteByte(c)
152+
capNext = false
153+
case c >= '0' && c <= '9':
154+
csharpBase.WriteByte(c)
155+
capNext = true
156+
default:
157+
capNext = true
158+
}
144159
}
145-
146-
// Also check vendor symlink path
147-
searchDir := filepath.Join(vendorDir, modulePath, protoDir)
148-
vendorMatches, err := filepath.Glob(filepath.Join(searchDir, baseName+"*.pb.*"))
149-
if err != nil {
150-
return nil, err
160+
csharpFile := csharpBase.String()
161+
if csharpFile != "" && csharpFile[0] >= '0' && csharpFile[0] <= '9' &&
162+
strings.HasPrefix(baseName, "_") {
163+
csharpFile = "_" + csharpFile
151164
}
152165

153-
// Deduplicate by resolving to real paths
154-
// Prefer non-vendor paths over vendor paths (they may be symlinked to the same file)
155-
seen := make(map[string]string) // realPath -> relativePath
166+
var relativePatterns []string
167+
if langs.Has(LanguageCpp) {
168+
relativePatterns = append(relativePatterns, baseName+".pb.cc", baseName+".pb.h")
169+
}
170+
if langs.Has(LanguageGo) {
171+
relativePatterns = append(relativePatterns, baseName+"*.pb.go")
172+
}
173+
if langs.Has(LanguageTypeScript) {
174+
relativePatterns = append(relativePatterns, baseName+"*.pb.ts")
175+
}
176+
if langs.Has(LanguageRust) {
177+
relativePatterns = append(relativePatterns, baseName+"*.pb.rs")
178+
}
179+
if langs.Has(LanguagePython) {
180+
relativePatterns = append(relativePatterns, baseName+"_pb2.py", baseName+"_pb2.pyi")
181+
}
156182

157-
// Process local matches first (preferred)
158-
for _, m := range localMatches {
159-
rel, err := filepath.Rel(projectDir, m)
160-
if err != nil {
161-
rel = m
162-
}
163-
realPath, err := filepath.EvalSymlinks(m)
164-
if err != nil {
165-
realPath = m
166-
}
167-
seen[realPath] = rel
183+
searches := []struct {
184+
dir string
185+
patterns []string
186+
}{
187+
{dir: filepath.Join(projectDir, protoDir), patterns: relativePatterns},
188+
{dir: filepath.Join(vendorDir, modulePath, protoDir), patterns: relativePatterns},
189+
}
190+
if langs.Has(LanguageCSharp) {
191+
searches = append(searches, struct {
192+
dir string
193+
patterns []string
194+
}{
195+
dir: projectDir,
196+
patterns: []string{csharpFile + ".cs"},
197+
})
168198
}
169199

170-
// Process vendor matches, only add if not already seen
171-
for _, m := range vendorMatches {
172-
realPath, err := filepath.EvalSymlinks(m)
173-
if err != nil {
174-
realPath = m
175-
}
176-
if _, exists := seen[realPath]; !exists {
177-
rel, err := filepath.Rel(projectDir, m)
200+
// Deduplicate by resolving to real paths. Prefer project-local paths over
201+
// their vendor-symlink aliases.
202+
seen := make(map[string]string)
203+
for _, search := range searches {
204+
for _, pattern := range search.patterns {
205+
matches, err := filepath.Glob(filepath.Join(search.dir, pattern))
178206
if err != nil {
179-
rel = m
207+
return nil, err
208+
}
209+
for _, match := range matches {
210+
realPath, err := filepath.EvalSymlinks(match)
211+
if err != nil {
212+
realPath = match
213+
}
214+
if _, exists := seen[realPath]; exists {
215+
continue
216+
}
217+
rel, err := filepath.Rel(projectDir, match)
218+
if err != nil {
219+
rel = match
220+
}
221+
seen[realPath] = rel
180222
}
181-
seen[realPath] = rel
182223
}
183224
}
184225

185-
// Collect results and sort for deterministic output.
186226
relPaths := make([]string, 0, len(seen))
187227
for _, rel := range seen {
188228
relPaths = append(relPaths, rel)
189229
}
190230
slices.Sort(relPaths)
191-
192231
return relPaths, nil
193232
}

0 commit comments

Comments
 (0)