diff --git a/.fastly/config.toml b/.fastly/config.toml index 3707fb2af..fb3751aa7 100644 --- a/.fastly/config.toml +++ b/.fastly/config.toml @@ -29,6 +29,11 @@ wasm_wasi_target = "wasm32-wasip1" toolchain_constraint = ">= 14.0.0" wasm_wasi_target = "wasm32-wasip1" +[language.python] +# The fastly-compute-py tool bundles Python 3.14 for the actual WASM build +toolchain_constraint = ">= 3.12" +uv_constraint = ">= 0.5.0" + [wasm-tools] ttl = "24h" diff --git a/CHANGELOG.md b/CHANGELOG.md index a931c9ae0..98ec21c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Enhancements: - feat(dns): add support for DNS Zones and TSIG Keys ([#1809](https://github.com/fastly/cli/pull/1809)) - fix(compute/init): Add starter kits for C++ language [#1807](https://github.com/fastly/cli/pull/1807) +- feat(compute/init): add support for Python language [#1811](https://github.com/fastly/cli/pull/1811) ### Dependencies: - build(deps): `github.com/bodgit/sevenzip` from 1.6.1 to 1.6.2 ([#1795](https://github.com/fastly/cli/pull/1795)) diff --git a/pkg/commands/compute/build.go b/pkg/commands/compute/build.go index 42d8ed8a8..ec0878707 100644 --- a/pkg/commands/compute/build.go +++ b/pkg/commands/compute/build.go @@ -330,8 +330,6 @@ func (c *BuildCommand) AnnotateWasmBinaryLong(wasmtools string, args []string, l // Allow customer to specify their own env variables to be filtered. ExtendStaticSecretEnvVars(c.MetadataFilterEnvVars) - dc := DataCollection{} - metadata := c.Globals.Config.WasmMetadata // Only record basic data if user has disabled all other metadata collection. @@ -339,6 +337,14 @@ func (c *BuildCommand) AnnotateWasmBinaryLong(wasmtools string, args []string, l return c.AnnotateWasmBinaryShort(wasmtools, args) } + // Seed from any fastly_data already embedded by the build tool (e.g. Python). + // This lets the build tool supply package_info while the CLI fills in the + // remaining fields it is responsible for. + dc := DataCollection{} + if existing := c.readExistingFastlyData(wasmtools); existing != nil { + dc = *existing + } + if metadata.BuildInfo == "enable" { dc.BuildInfo = DataCollectionBuildInfo{ MemoryHeapAlloc: bucketMB(bytesToMB(ms.HeapAlloc)) + "MB", @@ -354,9 +360,11 @@ func (c *BuildCommand) AnnotateWasmBinaryLong(wasmtools string, args []string, l } } if metadata.PackageInfo == "enable" { - dc.PackageInfo = DataCollectionPackageInfo{ - ClonedFrom: c.Globals.Manifest.File.ClonedFrom, - Packages: language.Dependencies(), + if dc.PackageInfo.Packages == nil { + dc.PackageInfo.Packages = language.Dependencies() + } + if dc.PackageInfo.ClonedFrom == "" { + dc.PackageInfo.ClonedFrom = c.Globals.Manifest.File.ClonedFrom } } if metadata.ScriptInfo == "enable" { @@ -377,6 +385,63 @@ func (c *BuildCommand) AnnotateWasmBinaryLong(wasmtools string, args []string, l return c.Globals.ExecuteWasmTools(wasmtools, args, c.Globals) } +// readExistingFastlyData reads any fastly_data already embedded in the Wasm +// binary by the build tool. Returns nil if absent or unparseable. +func (c *BuildCommand) readExistingFastlyData(wasmtools string) *DataCollection { + // #nosec G204 -- wasmtools path comes from trusted CLI config + out, err := exec.Command(wasmtools, "metadata", "show", "--json", binWasmPath).Output() + if err != nil { + return nil + } + + // wasm-tools metadata show --json encodes producers differently for modules and components: + // - Wasm Modules: {"module":{"producers":[...]}} + // - Wasm Components: {"component":{"metadata":{"producers":[...]}}} + var meta struct { + Module struct { + Producers []json.RawMessage `json:"producers"` + } `json:"module"` + Component struct { + Metadata struct { + Producers []json.RawMessage `json:"producers"` + } `json:"metadata"` + } `json:"component"` + } + if err := json.Unmarshal(out, &meta); err != nil { + return nil + } + + producers := meta.Component.Metadata.Producers + if len(producers) == 0 { + producers = meta.Module.Producers + } + + for _, raw := range producers { + var pair [2]json.RawMessage + if err := json.Unmarshal(raw, &pair); err != nil { + continue + } + var field string + if err := json.Unmarshal(pair[0], &field); err != nil || field != "processed-by" { + continue + } + var entries map[string]string + if err := json.Unmarshal(pair[1], &entries); err != nil { + continue + } + val, ok := entries["fastly_data"] + if !ok { + continue + } + var dc DataCollection + if err := json.Unmarshal([]byte(val), &dc); err != nil { + return nil + } + return &dc + } + return nil +} + // ShowMetadata displays the metadata attached to the Wasm binary. func (c *BuildCommand) ShowMetadata(wasmtools string, out io.Writer) { // gosec flagged this: @@ -715,6 +780,12 @@ func language(toolchain, manifestFilename string, c *BuildCommand, in io.Reader, SourceDirectory: JsSourceDirectory, Toolchain: NewJavaScript(c, in, manifestFilename, out, spinner), }) + case "python": + language = NewLanguage(&LanguageOptions{ + Name: "python", + SourceDirectory: PythonSourceDirectory, + Toolchain: NewPython(c, in, manifestFilename, out, spinner), + }) case "rust": language = NewLanguage(&LanguageOptions{ Name: "rust", diff --git a/pkg/commands/compute/build_metadata_internal_test.go b/pkg/commands/compute/build_metadata_internal_test.go new file mode 100644 index 000000000..e7e711e18 --- /dev/null +++ b/pkg/commands/compute/build_metadata_internal_test.go @@ -0,0 +1,121 @@ +package compute + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// mockWasmToolsScript generates a mock executable shell script for wasm-tools. +// +// Because the production code runs exec.Command under the hood, we mock it by writing +// a temporary executable bash script to disk that outputs the mock JSON we expect. +// We use a bash heredoc (cat << 'EOF') so that the JSON structure and inner quotes +// are written exactly as-is, avoiding platform-specific shell escape/echo issues. +func mockWasmToolsScript(staticOutput string) string { + return "#!/usr/bin/env bash\ncat << 'EOF'\n" + staticOutput + "\nEOF" +} + +func TestReadExistingFastlyData(t *testing.T) { + // Create a temporary directory for our mock environment + rootdir, err := os.MkdirTemp("", "fastly-metadata-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(rootdir) + + // Save original PWD and return to it later + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + // Ensure the bin directory and main.wasm file exist + // (binWasmPath points to "./bin/main.wasm") + if err := os.MkdirAll("bin", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(binWasmPath, []byte("mock-wasm-binary"), 0o600); err != nil { + t.Fatal(err) + } + + scenarios := []struct { + name string + jsonOutput string + expectedData *DataCollection + }{ + { + name: "extracts from component-based metadata structure", + jsonOutput: `{"component":{"metadata":{"producers":[["processed-by",{"fastly_data":"{\"package_info\":{\"packages\":{\"foo\":\"1.0.0\"}},\"script_info\":{\"build_script\":\"echo component\"}}"}]]}}}`, + expectedData: &DataCollection{ + PackageInfo: DataCollectionPackageInfo{ + Packages: map[string]string{"foo": "1.0.0"}, + }, + ScriptInfo: DataCollectionScriptInfo{ + BuildScript: "echo component", + }, + }, + }, + { + name: "extracts from module-based metadata structure", + jsonOutput: `{"module":{"producers":[["processed-by",{"fastly_data":"{\"package_info\":{\"packages\":{\"bar\":\"2.0.0\"}},\"script_info\":{\"build_script\":\"echo module\"}}"}]]}}`, + expectedData: &DataCollection{ + PackageInfo: DataCollectionPackageInfo{ + Packages: map[string]string{"bar": "2.0.0"}, + }, + ScriptInfo: DataCollectionScriptInfo{ + BuildScript: "echo module", + }, + }, + }, + { + name: "handles missing fastly_data gracefully", + jsonOutput: `{"component":{"metadata":{"producers":[["processed-by",{"other_tool":"1.0.0"}]]}}}`, + expectedData: nil, + }, + { + name: "handles invalid JSON from wasm-tools gracefully", + jsonOutput: `invalid-json`, + expectedData: nil, + }, + } + + for _, tc := range scenarios { + t.Run(tc.name, func(t *testing.T) { + wasmtoolsBin := filepath.Join(rootdir, "mock-wasm-tools") + scriptContent := mockWasmToolsScript(tc.jsonOutput) + // #nosec G306 -- mock binary must be executable + if err := os.WriteFile(wasmtoolsBin, []byte(scriptContent), 0o700); err != nil { + t.Fatal(err) + } + + cmd := &BuildCommand{} + actualData := cmd.readExistingFastlyData(wasmtoolsBin) + + if tc.expectedData == nil { + if actualData != nil { + t.Fatalf("expected nil, got: %+v", actualData) + } + return + } + + if actualData == nil { + t.Fatal("expected non-nil DataCollection, got nil") + } + + // Validate values + expectedBytes, _ := json.Marshal(tc.expectedData) + actualBytes, _ := json.Marshal(actualData) + if string(expectedBytes) != string(actualBytes) { + t.Errorf("\nwant: %s\ngot: %s", expectedBytes, actualBytes) + } + }) + } +} diff --git a/pkg/commands/compute/init.go b/pkg/commands/compute/init.go index c75f7a2ef..ea5fd3b7c 100644 --- a/pkg/commands/compute/init.go +++ b/pkg/commands/compute/init.go @@ -57,7 +57,7 @@ type InitCommand struct { } // Languages is a list of supported language options. -var Languages = []string{"rust", "javascript", "go", "cpp", "other"} +var Languages = []string{"rust", "javascript", "go", "cpp", "python", "other"} // NewInitCommand returns a usable command registered under the parent. func NewInitCommand(parent argparser.Registerer, g *global.Data) *InitCommand { @@ -782,6 +782,18 @@ func (c *InitCommand) PromptForStarterKit(kits []config.StarterKit, in io.Reader var option string flags := c.Globals.Flags + if len(kits) == 0 { + if flags.AcceptDefaults || flags.NonInteractive { + return "", "", "", errors.New("no default starter kits configured for this language; please specify a template using the --from flag") + } + text.Info(out, "\nNo default starter kits are currently configured for this language.") + option, err = text.Input(out, "Please paste a template git URL: ", in, nil) + if err != nil { + return "", "", "", fmt.Errorf("error reading input: %w", err) + } + return option, "", "", nil + } + if !flags.AcceptDefaults && !flags.NonInteractive { text.Output(out, "\n%s", text.Bold("Starter kit:")) for i, kit := range kits { diff --git a/pkg/commands/compute/language.go b/pkg/commands/compute/language.go index c39019bcb..e921d5537 100644 --- a/pkg/commands/compute/language.go +++ b/pkg/commands/compute/language.go @@ -42,6 +42,11 @@ func NewLanguages(kits config.StarterKitLanguages) []*Language { DisplayName: "C++", StarterKits: kits.CPP, }), + NewLanguage(&LanguageOptions{ + Name: "python", + DisplayName: "Python", + StarterKits: kits.Python, + }), NewLanguage(&LanguageOptions{ Name: "other", DisplayName: "Other ('bring your own' Wasm binary)", diff --git a/pkg/commands/compute/language_python.go b/pkg/commands/compute/language_python.go new file mode 100644 index 000000000..75ec9847c --- /dev/null +++ b/pkg/commands/compute/language_python.go @@ -0,0 +1,248 @@ +package compute + +import ( + "fmt" + "io" + "os/exec" + "regexp" + "strings" + + "github.com/Masterminds/semver/v3" + + "github.com/fastly/cli/pkg/config" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" +) + +// PythonDefaultBuildCommand is the default build command for Python projects. +// Uses uv (modern Python package manager) to run fastly-compute-py build tool. +var PythonDefaultBuildCommand = fmt.Sprintf("uv run fastly-compute-py build -o %s", binWasmPath) + +// PythonSourceDirectory represents the source code directory. +// Python projects typically use the root directory rather than a src/ subdirectory. +const PythonSourceDirectory = "." + +// PythonManifest is the manifest file for Python projects. +const PythonManifest = "pyproject.toml" + +// NewPython constructs a new Python toolchain. +func NewPython( + c *BuildCommand, + in io.Reader, + manifestFilename string, + out io.Writer, + spinner text.Spinner, +) *Python { + return &Python{ + Shell: Shell{}, + + autoYes: c.Globals.Flags.AutoYes, + build: c.Globals.Manifest.File.Scripts.Build, + config: c.Globals.Config.Language.Python, + env: c.Globals.Manifest.File.Scripts.EnvVars, + errlog: c.Globals.ErrLog, + input: in, + manifestFilename: manifestFilename, + metadataFilterEnvVars: c.MetadataFilterEnvVars, + nonInteractive: c.Globals.Flags.NonInteractive, + output: out, + postBuild: c.Globals.Manifest.File.Scripts.PostBuild, + spinner: spinner, + timeout: c.Flags.Timeout, + verbose: c.Globals.Verbose(), + } +} + +// Python implements a Toolchain for the Python language. +type Python struct { + Shell + + // autoYes is the --auto-yes flag. + autoYes bool + // build is a shell command defined in fastly.toml using [scripts.build]. + build string + // config is the Python language configuration from config.toml. + config config.Python + // defaultBuild indicates if the default build script was used. + defaultBuild bool + // env is environment variables to be set. + env []string + // errlog is an abstraction for recording errors to disk. + errlog fsterr.LogInterface + // input is the user's terminal stdin stream + input io.Reader + // manifestFilename is the name of the manifest file. + manifestFilename string + // metadataFilterEnvVars is a comma-separated list of user defined env vars. + metadataFilterEnvVars string + // nonInteractive is the --non-interactive flag. + nonInteractive bool + // output is the users terminal stdout stream + output io.Writer + // postBuild is a custom script executed after the build but before the Wasm + // binary is added to the .tar.gz archive. + postBuild string + // spinner is a terminal progress status indicator. + spinner text.Spinner + // timeout is the build execution threshold. + timeout int + // verbose indicates if the user set --verbose + verbose bool +} + +// DefaultBuildScript indicates if a custom build script was used. +func (p *Python) DefaultBuildScript() bool { + return p.defaultBuild +} + +// Dependencies returns the project's Python package dependencies. +// +// For Python, fastly-compute-py injects fastly_data directly into the Wasm +// during the build, so the CLI does not need to collect dependencies separately. +// The CLI's AnnotateWasmBinaryLong merges with the existing fastly_data rather +// than overwriting it. +func (p *Python) Dependencies() map[string]string { + return make(map[string]string) +} + +// Build compiles the user's source code into a Wasm binary. +func (p *Python) Build() error { + // Check Python and UV versions before attempting build + if err := p.toolchainConstraint(); err != nil { + return err + } + + if p.build == "" { + p.build = PythonDefaultBuildCommand + p.defaultBuild = true + + if p.verbose { + text.Info(p.output, "No custom build script found in fastly.toml [scripts.build].\n") + text.Info(p.output, "Using default build command: %s\n\n", p.build) + } + } + + bt := BuildToolchain{ + autoYes: p.autoYes, + buildFn: p.Shell.Build, + buildScript: p.build, + env: p.env, + errlog: p.errlog, + in: p.input, + manifestFilename: p.manifestFilename, + metadataFilterEnvVars: p.metadataFilterEnvVars, + nonInteractive: p.nonInteractive, + out: p.output, + postBuild: p.postBuild, + spinner: p.spinner, + timeout: p.timeout, + verbose: p.verbose, + } + + return bt.Build() +} + +// toolchainConstraint validates that the Python and UV toolchains meet version requirements. +func (p *Python) toolchainConstraint() error { + // Check Python version + if err := p.checkPythonVersion(); err != nil { + return err + } + + // Check UV is installed + if err := p.checkUVInstalled(); err != nil { + return err + } + + return nil +} + +// checkPythonVersion validates the Python version meets the minimum requirement. +func (p *Python) checkPythonVersion() error { + requiredConstraint := p.config.ToolchainConstraint + if requiredConstraint == "" { + requiredConstraint = ">= 3.11" + } + + if p.verbose { + text.Info(p.output, "Checking Python version (required: %s)...\n\n", requiredConstraint) + } + + // Try 'python' first, then 'python3' + var cmd *exec.Cmd + var stdout []byte + var err error + + cmd = exec.Command("python", "--version") + stdout, err = cmd.CombinedOutput() + if err != nil { + // Try python3 + cmd = exec.Command("python3", "--version") + stdout, err = cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("Python not found in PATH. Please install Python %s or later", requiredConstraint) + } + } + + output := strings.TrimSpace(string(stdout)) + + // Parse "Python 3.12.11" format + versionPattern := regexp.MustCompile(`Python (?P\d+\.\d+\.\d+)`) + match := versionPattern.FindStringSubmatch(output) + if len(match) < 2 { + return fmt.Errorf("unable to parse Python version from: %s", output) + } + + versionStr := match[1] + v, err := semver.NewVersion(versionStr) + if err != nil { + return fmt.Errorf("invalid Python version '%s': %w", versionStr, err) + } + + c, err := semver.NewConstraint(requiredConstraint) + if err != nil { + return fmt.Errorf("invalid toolchain constraint '%s': %w", requiredConstraint, err) + } + + valid, errs := c.Validate(v) + if !valid { + var errMsgs []string + for _, e := range errs { + errMsgs = append(errMsgs, e.Error()) + } + return fmt.Errorf("Python version %s does not satisfy constraint %s: %s", v, requiredConstraint, strings.Join(errMsgs, ", ")) + } + + if p.verbose { + text.Success(p.output, "Python version %s meets requirement %s\n\n", v, requiredConstraint) + } + + return nil +} + +// checkUVInstalled validates that UV package manager is installed. +func (p *Python) checkUVInstalled() error { + if p.verbose { + text.Info(p.output, "Checking for UV package manager...\n\n") + } + + cmd := exec.Command("uv", "--version") + output, err := cmd.Output() + if err != nil { + text.Break(p.output) + text.Error(p.output, "UV package manager not found in PATH.\n") + text.Info(p.output, "\nUV is required to build Python applications for Fastly Compute.\n") + text.Info(p.output, "Install UV: https://docs.astral.sh/uv/\n") + text.Info(p.output, "\nQuick install:\n") + text.Info(p.output, " macOS/Linux: curl -LsSf https://astral.sh/uv/install.sh | sh\n") + text.Info(p.output, " Windows: powershell -c \"irm https://astral.sh/uv/install.ps1 | iex\"\n\n") + return fmt.Errorf("uv not found in PATH") + } + + if p.verbose { + uvVersion := strings.TrimSpace(string(output)) + text.Success(p.output, "UV found: %s\n\n", uvVersion) + } + + return nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 7896c0589..f6eeabb80 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -104,9 +104,10 @@ type Versioner struct { // Language represents Compute language specific configuration. type Language struct { - CPP CPP `toml:"cpp"` - Go Go `toml:"go"` - Rust Rust `toml:"rust"` + CPP CPP `toml:"cpp"` + Go Go `toml:"go"` + Python Python `toml:"python"` + Rust Rust `toml:"rust"` } // Go represents Go Compute language specific configuration. @@ -148,6 +149,15 @@ type CPP struct { WasmWasiTarget string `toml:"wasm_wasi_target"` } +// Python represents Python Compute language specific configuration. +type Python struct { + // ToolchainConstraint is the Python version that we support (host Python). + ToolchainConstraint string `toml:"toolchain_constraint"` + + // UVConstraint is the UV package manager version (optional, for warnings). + UVConstraint string `toml:"uv_constraint"` +} + // Profiles represents multiple profile accounts. type Profiles map[string]*Profile @@ -183,6 +193,7 @@ type StarterKitLanguages struct { Go []StarterKit `toml:"go"` JavaScript []StarterKit `toml:"javascript"` Rust []StarterKit `toml:"rust"` + Python []StarterKit `toml:"python"` } // StarterKit represents starter kit specific configuration.