diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..de28c42 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,59 @@ +name: Release Please + +on: + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + releases_created: ${{ steps.release.outputs.releases_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - uses: actions/checkout@v4 + + - uses: googleapis/release-please-action@v5 + id: release + with: + config-file: .release-please-config.json + manifest-file: .release-please-manifest.json + target-branch: master + + # Build & publish binaries only when release-please actually cut a release + # (i.e. a release-please PR was just merged to master). + publish: + needs: release-please + if: ${{ needs.release-please.outputs.releases_created == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + fetch-depth: 0 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: latest + # release-please already created the GitHub Release (notes, tag); + # GoReleaser just builds & uploads the binary archives to it. + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 1f3a6eb..d5791e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ # IDE files .idea/ *.iml + +# goreleaser build output +/dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..17a298d --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,51 @@ +version: 2 +project_name: gopy + +before: + hooks: + - go mod tidy + +builds: + - id: gopy + main: . + binary: gopy + env: + - CGO_ENABLED=0 + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + ldflags: + - -s -w + - -X main.GitCommit={{ .ShortCommit }} + - -X 'main.VersionDate={{ .CommitDate }} UTC' + +archives: + - id: gopy + formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + +checksum: + name_template: "checksums.txt" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" + - "^Merge pull request" + +release: + github: + owner: go-python + name: gopy + # release-please already created the GitHub Release for this tag (with its + # own changelog-derived notes); keep those notes as-is and just attach + # the built archives/checksums to it. + mode: keep-existing + prerelease: auto + name_template: "{{ .Tag }}" diff --git a/.release-please-config.json b/.release-please-config.json new file mode 100644 index 0000000..a7d61ce --- /dev/null +++ b/.release-please-config.json @@ -0,0 +1,31 @@ +{ + "release-type": "go", + "include-v-in-tag": true, + "bootstrap-sha": "d09ff0f8254c8bd4a131157bf276c83330930cb5", + "packages": { + ".": { + "release-type": "go", + "package-name": "gopy", + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { + "path": "version.go", + "type": "generic" + } + ] + } + }, + "changelog-sections": [ + { "type": "feat", "section": "Features", "hidden": false }, + { "type": "fix", "section": "Bug Fixes", "hidden": false }, + { "type": "perf", "section": "Performance Improvements", "hidden": false }, + { "type": "revert", "section": "Reverts", "hidden": false }, + { "type": "docs", "section": "Documentation", "hidden": false }, + { "type": "style", "section": "Styles", "hidden": true }, + { "type": "chore", "section": "Miscellaneous Chores", "hidden": true }, + { "type": "refactor", "section": "Code Refactoring", "hidden": false }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build System", "hidden": false }, + { "type": "ci", "section": "Continuous Integration", "hidden": true } + ] +} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e7180db --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.4.10" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8bcd3a7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`gopy` is a Go program (module `github.com/go-python/gopy`) that generates and compiles CPython extension +modules from Go packages. It parses a target Go package with `go/packages` + `go/ast`, and emits both a cgo +shim (Go) and a `.py` wrapper module (using pybindgen under the hood), then optionally compiles them into an +importable Python module. + +## Build / test / lint commands + +Requires Go (module-based build, see `go.mod` for the minimum toolchain version) plus a Python 3 interpreter, +`pybindgen`, and `goimports` on `PATH`: + +```sh +python3 -m pip install pybindgen +go install golang.org/x/tools/cmd/goimports@latest +``` + +- Build the `gopy` binary: `make` (or `go build -v ./...`) +- Run the full test suite: `make test` (or `go test -v ./...`) +- Run a single test: `go test -v -run=TestHi` (test names correspond 1:1 to `_examples/*` fixtures, e.g. + `TestHi` -> `_examples/hi`, `TestBindStructs` -> `_examples/structs`) +- `go vet`: `make vet`, also runs as `TestGovet` inside the suite +- Format: `make fmts` (gofmt -s -w); import-formatting is also checked by `TestGofmt` (uses `goimports` if + present, falls back to `gofmt`) +- `go.mod`/dependency housekeeping: `make tidy`, `make mod-update` + +On Windows, tests that check for memory leaks require `psutil` (`python -m pip install psutil`) since the +`resource` module is Unix-only. + +### Enforced minimum Python version + +There is no hard-coded minimum in the Go code itself — `getPythonVersion` (python.go) only checks that +`sys.version_info.major == 3` (Python 2 is explicitly rejected, no minor-version floor is asserted). +In practice, treat **Python 3.11** as the baseline: it's the only interpreter version CI installs +(`actions/setup-python` pinned to `'3.11'` in `.github/workflows/ci.yml`) and exercises across all three +platforms (Linux/macOS/Windows) and the whole Go version matrix. Nothing in the generated-code templates +(`bind/gen_*.go`, `pkgsetup.go`) or the example fixtures (`_examples/*/test.py`) uses syntax newer than +basic Python 3 (no f-strings, walrus operator, structural pattern matching, or type-hint syntax appear in +emitted code), so there's no code-level reason to require anything past 3.11 — that pin is just what's +actually validated. + +## Architecture + +**Entry point & commands** (`main.go`, `cmd_*.go`): `main.go` wires up four `commander` subcommands, each +thin wrappers around `genPkg` (gen.go): +- `gopy gen` (cmd_gen.go) — emit bindings only, no compile +- `gopy build` (cmd_build.go) — emit + compile a dynamic module for local testing (this is what the test + suite in main_test.go uses for every `_examples/*` fixture) +- `gopy pkg` (cmd_pkg.go) — like build, plus generates a full installable Python package (`setup.py`, + `MANIFEST.in`, `LICENSE`, `Makefile` — see pkgsetup.go) +- `gopy exe` (cmd_exe.go) — like pkg, but produces a standalone executable with Go's `main()` retained + (for cases like GUI event loops that need the real OS main thread) + +These four map to `bind.BuildMode` (`ModeGen`/`ModeBuild`/`ModePkg`/`ModeExe`, defined in bind/gen.go), which +is threaded through the generator to decide what gets emitted. + +**Package loading** (gen.go): `loadPackage` uses `golang.org/x/tools/go/packages` to resolve and type-check +the target Go package (optionally `go build`-ing it first), then `parsePackage` re-parses it with `go/ast` + +`go/doc` to recover doc comments, producing a `bind.Package` (bind/package.go) that the generator walks. + +**Binding generation** (`bind/` package): this is the core of the tool. +- `gen.go` / `printer.go` — top-level generator (`Generator`) orchestrating output: a cgo `.go`/`.h`/`.c` + file and a Python wrapper `.py` file, tuned per `BuildMode`. +- `gen_func.go`, `gen_struct.go`, `gen_type.go`, `gen_slice.go`, `gen_map.go`, `gen_varconst.go` — one file + per Go language construct being bound (functions/methods, structs -> Python classes, named types, slices, + maps, vars/consts). Struct embedding is mapped to Python class inheritance (first embedded field only). +- `stdtypes.go`, `types.go`, `symbols.go` — Go<->Python type mapping tables, symbol table / name-collision + and Python-keyword handling (structs and generated symbols get an int64 opaque "handle" instead of a raw + pointer, so nothing unsafe crosses the C/Python GC boundary). +- `utils.go` — shared codegen helpers. + +**Testing model**: there's no separate unit-test layer for the generator — correctness is validated +end-to-end. Each subdirectory of `_examples/` is a small Go package plus a hand-written `test.py`. +main_test.go's `testPkg` helper runs `gopy build` on the example package, then executes its `test.py` +against the compiled module and diffs stdout against an expected `want` byte string embedded in the test +function. `TestCheckSupportMatrix` regenerates SUPPORT_MATRIX.md from the `features` map at the top of +main_test.go — update that map (not SUPPORT_MATRIX.md directly) when adding/removing an example. Platform +differences live in main_unix_test.go / main_windows_test.go (`//go:build` tags), e.g. picking which Python +VM name to probe for and whether memory-leak checks run. + +**Versioning**: releases are managed by release-please (`.github/workflows/release-please.yml`, +`.release-please-config.json`, `.release-please-manifest.json`). Merging its release PR to `master` bumps +the `Version` var in version.go (via the `x-release-please-version` marker — don't hand-edit that line), +tags the commit, and publishes the GitHub Release; a follow-on job then runs GoReleaser +(`.goreleaser.yaml`) to build and attach binary archives to that release. `GitCommit` and `VersionDate` are +no longer stored in version.go — they're stamped at build time via `-ldflags` (see the `build` target in +Makefile and the `builds.ldflags` entry in .goreleaser.yaml). diff --git a/Makefile b/Makefile index 604892a..e4cdd8b 100644 --- a/Makefile +++ b/Makefile @@ -11,11 +11,15 @@ DIRS=`go list ./...` PYTHON=python3 PIP=$(PYTHON) -m pip +GIT_COMMIT=`git rev-parse --short HEAD` +VERS_DATE=`date -u +%Y-%m-%d\ %H:%M` +LDFLAGS=-X 'main.GitCommit=$(GIT_COMMIT)' -X 'main.VersionDate=$(VERS_DATE) UTC' + all: build -build: +build: @echo "GO111MODULE = $(value GO111MODULE)" - $(GOBUILD) -v $(DIRS) + $(GOBUILD) -v -ldflags "$(LDFLAGS)" $(DIRS) test: @echo "GO111MODULE = $(value GO111MODULE)" @@ -52,30 +56,7 @@ prereq: @echo " _PyInit__gi, referenced from:..." @echo - -# NOTE: MUST update version number here prior to running 'make release' and edit this file! -VERS=v0.4.10 -PACKAGE=main -GIT_COMMIT=`git rev-parse --short HEAD` -VERS_DATE=`date -u +%Y-%m-%d\ %H:%M` -VERS_FILE=version.go - -release: - /bin/rm -f $(VERS_FILE) - @echo "// WARNING: auto-generated by Makefile release target -- run 'make release' to update" > $(VERS_FILE) - @echo "" >> $(VERS_FILE) - @echo "package $(PACKAGE)" >> $(VERS_FILE) - @echo "" >> $(VERS_FILE) - @echo "const (" >> $(VERS_FILE) - @echo " Version = \"$(VERS)\"" >> $(VERS_FILE) - @echo " GitCommit = \"$(GIT_COMMIT)\" // the commit JUST BEFORE the release" >> $(VERS_FILE) - @echo " VersionDate = \"$(VERS_DATE)\" // UTC" >> $(VERS_FILE) - @echo ")" >> $(VERS_FILE) - @echo "" >> $(VERS_FILE) - goimports -w $(VERS_FILE) - /bin/cat $(VERS_FILE) - git commit -am "$(VERS) release" - git tag -a $(VERS) -m "$(VERS) release" - git push - git push origin --tags +# Releases are managed by release-please (.github/workflows/release-please.yml): +# merging its release PR to master bumps version.go, tags the commit, and +# publishes the GitHub Release. GoReleaser then builds & uploads binaries to it. diff --git a/bind/bind.go b/bind/bind.go index a3b7a85..93e78af 100644 --- a/bind/bind.go +++ b/bind/bind.go @@ -28,6 +28,9 @@ type BindCfg struct { PkgPrefix string // rename Go exported symbols to python PEP snake_case RenameCase bool + // gopy version string embedded in this binary, stamped into generated + // file headers so output can be traced back to the release that produced it + Version string } // ErrorList is a list of errors diff --git a/bind/gen.go b/bind/gen.go index f6d8604..75cc3de 100644 --- a/bind/gen.go +++ b/bind/gen.go @@ -38,11 +38,11 @@ var WindowsOS = false // for all preambles: 1 = name of package (outname), 2 = cmdstr -// 3 = libcfg, 4 = GoHandle, 5 = CGoHandle, 6 = all imports, 7 = mainstr, 8 = exe pre C, 9 = exe pre go +// 3 = libcfg, 4 = GoHandle, 5 = CGoHandle, 6 = all imports, 7 = mainstr, 8 = exe pre C, 9 = exe pre go, 10 = gopy version const ( goPreamble = `/* cgo stubs for package %[1]s. -File is generated by gopy. Do not edit. +File is generated by gopy version %[10]s. Do not edit. %[2]s */ @@ -257,8 +257,9 @@ func GoPyMainRun() { ` + // 3 = gopy version PyBuildPreamble = `# python build stubs for package %[1]s -# File is generated by gopy. Do not edit. +# File is generated by gopy version %[3]s. Do not edit. # %[2]s from pybindgen import retval, param, Function, Module @@ -311,11 +312,11 @@ mod.add_function('_gopy_clear_go_tls', None, []) // appended to imports in py wrap preamble as key for adding at end importHereKeyString = "%%%%%%<<<<<>>>>>>%%%%%%%" - // 3 = specific package name, 4 = spec pkg path, 5 = doc, 6 = imports + // 3 = specific package name, 4 = spec pkg path, 5 = doc, 6 = imports, 8 = gopy version PyWrapPreamble = `%[5]s # python wrapper for package %[4]s within overall package %[1]s # This is what you import to use the package. -# File is generated by gopy. Do not edit. +# File is generated by gopy version %[8]s. Do not edit. # %[2]s # the following is required to enable dlopen to open the _go.so file @@ -373,11 +374,11 @@ except Exception: ` // exe version of preamble -- doesn't need complex code to load _ module - // 3 = specific package name, 4 = spec pkg path, 5 = doc, 6 = imports + // 3 = specific package name, 4 = spec pkg path, 5 = doc, 6 = imports, 8 = gopy version PyWrapExePreamble = `%[5]s # python wrapper for package %[4]s within standalone executable package %[1]s # This is what you import to use the package. -# File is generated by gopy. Do not edit. +# File is generated by gopy version %[8]s. Do not edit. # %[2]s import collections @@ -425,9 +426,9 @@ def Init(): ` // 3 = gencmd, 4 = vm, 5 = libext 6 = extraGccArgs, 7 = CFLAGS, 8 = LDLFAGS, - // 9 = windows special declspec hack + // 9 = windows special declspec hack, 10 = gopy version MakefileTemplate = `# Makefile for python interface for package %[1]s. -# File is generated by gopy. Do not edit. +# File is generated by gopy version %[10]s. Do not edit. # %[2]s GOCMD=go @@ -464,9 +465,9 @@ build: ` - // exe version of template: 3 = gencmd, 4 = vm, 5 = libext + // exe version of template: 3 = gencmd, 4 = vm, 5 = libext, 8 = gopy version MakefileExeTemplate = `# Makefile for python interface for standalone executable package %[1]s. -# File is generated by gopy. Do not edit. +# File is generated by gopy version %[8]s. Do not edit. # %[2]s GOCMD=go @@ -715,12 +716,12 @@ func (g *pyGen) genGoPreamble() { exeprego = goExePreambleGo } g.gofile.Printf(goPreamble, g.cfg.Name, g.cfg.Cmd, libcfg, GoHandle, CGoHandle, - pkgimport, g.cfg.Main, exeprec, exeprego) + pkgimport, g.cfg.Main, exeprec, exeprego, g.cfg.Version) g.gofile.Printf("\n// --- generated code for package: %[1]s below: ---\n\n", g.cfg.Name) } func (g *pyGen) genPyBuildPreamble() { - g.pybuild.Printf(PyBuildPreamble, g.cfg.Name, g.cfg.Cmd) + g.pybuild.Printf(PyBuildPreamble, g.cfg.Name, g.cfg.Cmd, g.cfg.Version) } func (g *pyGen) genPyWrapPreamble() { @@ -778,9 +779,9 @@ func (g *pyGen) genPyWrapPreamble() { impstr += importHereKeyString if g.mode == ModeExe { - g.pywrap.Printf(PyWrapExePreamble, g.cfg.Name, g.cfg.Cmd, n, pkgimport, pkgDoc, impgenstr, impstr) + g.pywrap.Printf(PyWrapExePreamble, g.cfg.Name, g.cfg.Cmd, n, pkgimport, pkgDoc, impgenstr, impstr, g.cfg.Version) } else { - g.pywrap.Printf(PyWrapPreamble, g.cfg.Name, g.cfg.Cmd, n, pkgimport, pkgDoc, impgenstr, impstr) + g.pywrap.Printf(PyWrapPreamble, g.cfg.Name, g.cfg.Cmd, n, pkgimport, pkgDoc, impgenstr, impstr, g.cfg.Version) } } @@ -810,14 +811,14 @@ func (g *pyGen) genMakefile() { } if g.mode == ModeExe { - g.makefile.Printf(MakefileExeTemplate, g.cfg.Name, g.cfg.Cmd, gencmd, g.cfg.VM, g.libext, pycfg.CFlags, pycfg.LdFlags) + g.makefile.Printf(MakefileExeTemplate, g.cfg.Name, g.cfg.Cmd, gencmd, g.cfg.VM, g.libext, pycfg.CFlags, pycfg.LdFlags, g.cfg.Version) } else { winhack := "" if WindowsOS { winhack = fmt.Sprintf(`# windows-only sed hack here to fix pybindgen declaration of PyInit sed -i "s/ PyInit_/ __declspec(dllexport) PyInit_/g" %s.c`, g.cfg.Name) } - g.makefile.Printf(MakefileTemplate, g.cfg.Name, g.cfg.Cmd, gencmd, g.cfg.VM, g.libext, g.extraGccArgs, pycfg.CFlags, pycfg.LdFlags, winhack) + g.makefile.Printf(MakefileTemplate, g.cfg.Name, g.cfg.Cmd, gencmd, g.cfg.VM, g.libext, g.extraGccArgs, pycfg.CFlags, pycfg.LdFlags, winhack, g.cfg.Version) } } diff --git a/cmd_version.go b/cmd_version.go new file mode 100644 index 0000000..0419dc4 --- /dev/null +++ b/cmd_version.go @@ -0,0 +1,32 @@ +// Copyright 2026 The go-python Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "fmt" + + "github.com/gonuts/commander" + "github.com/gonuts/flag" +) + +func gopyMakeCmdVersion() *commander.Command { + return &commander.Command{ + Run: gopyRunCmdVersion, + UsageLine: "version", + Short: "print gopy version information", + Long: ` +version prints the gopy version, git commit, and build date embedded in this binary at release time. + +ex: + $ gopy version +`, + Flag: *flag.NewFlagSet("gopy-version", flag.ExitOnError), + } +} + +func gopyRunCmdVersion(cmdr *commander.Command, args []string) error { + fmt.Printf("gopy version %s (commit %s, built %s)\n", Version, GitCommit, VersionDate) + return nil +} diff --git a/main.go b/main.go index e59f56f..9e968ca 100644 --- a/main.go +++ b/main.go @@ -38,6 +38,7 @@ type BuildCfg struct { func NewBuildCfg() *BuildCfg { var cfg BuildCfg cfg.Cmd = argStr() + cfg.Version = Version return &cfg } @@ -49,6 +50,7 @@ func run(args []string) error { gopyMakeCmdBuild(), gopyMakeCmdPkg(), gopyMakeCmdExe(), + gopyMakeCmdVersion(), }, Flag: *flag.NewFlagSet("gopy", flag.ExitOnError), } diff --git a/main_test.go b/main_test.go index 1127698..0e76267 100644 --- a/main_test.go +++ b/main_test.go @@ -128,6 +128,47 @@ ignoring python incompatible function: .func github.com/go-python/gopy/_examples } } +func TestVersion(t *testing.T) { + cmd := exec.Command("go", "run", ".", "version") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("could not run %v: %+v\n%s", strings.Join(cmd.Args, " "), err, out) + } + got := strings.TrimSpace(string(out)) + want := fmt.Sprintf("gopy version %s (commit %s, built %s)", Version, GitCommit, VersionDate) + if got != want { + t.Fatalf("gopy version = %q, want %q", got, want) + } +} + +func TestGenHeaderHasVersion(t *testing.T) { + pyvm := testBackends["py3"] + workdir, err := os.MkdirTemp("", "gopy-") + if err != nil { + t.Fatalf("could not create workdir: %v\n", err) + } + defer os.RemoveAll(workdir) + + curPkgPath := reflect.TypeOf(pkg{}).PkgPath() + fpath := filepath.Join(curPkgPath, "_examples/hi") + cmd := exec.Command("go", "run", ".", "gen", "-vm="+pyvm, "-output="+workdir, fpath) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("could not run %v: %+v\n%s", strings.Join(cmd.Args, " "), err, out) + } + + want := fmt.Sprintf("generated by gopy version %s.", Version) + for _, fname := range []string{"hi.go", "build.py", "Makefile", "hi.py"} { + b, err := os.ReadFile(filepath.Join(workdir, fname)) + if err != nil { + t.Fatalf("could not read generated %s: %v", fname, err) + } + if !strings.Contains(string(b), want) { + t.Errorf("%s does not contain %q:\n%s", fname, want, string(b)) + } + } +} + func TestHi(t *testing.T) { // t.Parallel() path := "_examples/hi" diff --git a/version.go b/version.go index 55a0f48..efc2065 100644 --- a/version.go +++ b/version.go @@ -1,9 +1,13 @@ -// WARNING: auto-generated by Makefile release target -- run 'make release' to update +// Version is bumped by release-please via the x-release-please-version +// marker below; do not hand-edit the value on that line. +// +// GitCommit and VersionDate are stamped at build time via `go build -ldflags` +// (see Makefile and .goreleaser.yaml), so they default to "unknown" here. package main -const ( - Version = "v0.4.10" - GitCommit = "b735a58" // the commit JUST BEFORE the release - VersionDate = "2024-05-03 22:57" // UTC +var ( + Version = "v0.4.10" // x-release-please-version + GitCommit = "unknown" + VersionDate = "unknown" )