From d6c5dcf5224993516504da779a7afd0dd9b7a900 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Wed, 3 Jun 2026 13:26:53 -0500 Subject: [PATCH 01/12] Support merging existing Wasm metadata during build annotation Modify the metadata annotation step to read and preserve any fastly_data already embedded in the Wasm binary (e.g. by language-specific build tools like python). This allows build tools to supply package info directly while the CLI dynamically fills in remaining fields (like cloned repository info). --- pkg/commands/compute/build.go | 81 +++++++++++- .../compute/build_metadata_internal_test.go | 120 ++++++++++++++++++ 2 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 pkg/commands/compute/build_metadata_internal_test.go 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..3a444983f --- /dev/null +++ b/pkg/commands/compute/build_metadata_internal_test.go @@ -0,0 +1,120 @@ +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"), 0o644); 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) + if err := os.WriteFile(wasmtoolsBin, []byte(scriptContent), 0o755); 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) + } + }) + } +} From c7b818f75be40e1e60c48f4fe1edc313f1add47a Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Wed, 3 Jun 2026 13:27:38 -0500 Subject: [PATCH 02/12] Add support for the Python programming language Add configuration, toolchain parsing, version validation, and build scaffolding for Python projects inside the compute environment. Host Python >= 3.11 and uv are utilized as standard toolchain constraints. --- .fastly/config.toml | 5 + pkg/commands/compute/init.go | 2 +- pkg/commands/compute/language.go | 5 + pkg/commands/compute/language_python.go | 248 ++++++++++++++++++++++++ pkg/config/config.go | 17 +- 5 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 pkg/commands/compute/language_python.go 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/pkg/commands/compute/init.go b/pkg/commands/compute/init.go index c75f7a2ef..2ad8511b2 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 { 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. From 5fc35b29db5e07f9ad437a833745a0fcdb388eff Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Wed, 3 Jun 2026 13:31:57 -0500 Subject: [PATCH 03/12] Avoid panic when prompt language has empty starter kits Ensure PromptForStarterKit does not index out-of-bounds when there are no configured starter kits for a language. Instead, prompt the user for a template git URL directly, or fail gracefully if non-interactive. --- pkg/commands/compute/init.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/commands/compute/init.go b/pkg/commands/compute/init.go index 2ad8511b2..ea5fd3b7c 100644 --- a/pkg/commands/compute/init.go +++ b/pkg/commands/compute/init.go @@ -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 { From 088b021f4846c15a76cca5e5be1641f0d6661378 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Wed, 3 Jun 2026 16:32:47 -0500 Subject: [PATCH 04/12] Address gosec file permission warnings in metadata unit tests Use 0600 permissions for mock WASM files and add gosec ignore directives for mock executable creation. --- pkg/commands/compute/build_metadata_internal_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/commands/compute/build_metadata_internal_test.go b/pkg/commands/compute/build_metadata_internal_test.go index 3a444983f..e7e711e18 100644 --- a/pkg/commands/compute/build_metadata_internal_test.go +++ b/pkg/commands/compute/build_metadata_internal_test.go @@ -11,7 +11,7 @@ import ( // // 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 +// 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" @@ -42,7 +42,7 @@ func TestReadExistingFastlyData(t *testing.T) { if err := os.MkdirAll("bin", 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(binWasmPath, []byte("mock-wasm-binary"), 0o644); err != nil { + if err := os.WriteFile(binWasmPath, []byte("mock-wasm-binary"), 0o600); err != nil { t.Fatal(err) } @@ -91,7 +91,8 @@ func TestReadExistingFastlyData(t *testing.T) { t.Run(tc.name, func(t *testing.T) { wasmtoolsBin := filepath.Join(rootdir, "mock-wasm-tools") scriptContent := mockWasmToolsScript(tc.jsonOutput) - if err := os.WriteFile(wasmtoolsBin, []byte(scriptContent), 0o755); err != nil { + // #nosec G306 -- mock binary must be executable + if err := os.WriteFile(wasmtoolsBin, []byte(scriptContent), 0o700); err != nil { t.Fatal(err) } From dd0ac3259237b7b4e8f7e90107f25126a8baf7c4 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Wed, 3 Jun 2026 16:32:55 -0500 Subject: [PATCH 05/12] Update CHANGELOG.md to note Python language addition --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a931c9ae0..822d4292f 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 ### Dependencies: - build(deps): `github.com/bodgit/sevenzip` from 1.6.1 to 1.6.2 ([#1795](https://github.com/fastly/cli/pull/1795)) From c9baae9c02e7e246fffc5f09bf22e000bb697c54 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Thu, 4 Jun 2026 12:06:27 -0500 Subject: [PATCH 06/12] Update CHANGELOG.md Co-authored-by: Anthony Gomez --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 822d4292f..98ec21c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +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 +- 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)) From ac87f1c8e196f99df366611a11ba7da831083ead Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Thu, 4 Jun 2026 14:32:50 -0500 Subject: [PATCH 07/12] test(compute): add build test scenarios for Python language Introduce TestBuildPython to build_test.go covering: - Handling missing fastly.toml manifest - Handling of typical build-time failures - Dependency checks - Basic python example --- pkg/commands/compute/build_test.go | 165 ++++++++++++++++++ .../compute/testdata/build/python/main.py | 11 ++ .../testdata/build/python/pyproject.toml | 11 ++ .../compute/testdata/build/python/uv.lock | 27 +++ 4 files changed, 214 insertions(+) create mode 100644 pkg/commands/compute/testdata/build/python/main.py create mode 100644 pkg/commands/compute/testdata/build/python/pyproject.toml create mode 100644 pkg/commands/compute/testdata/build/python/uv.lock diff --git a/pkg/commands/compute/build_test.go b/pkg/commands/compute/build_test.go index e6ace9eb5..de2bfa189 100644 --- a/pkg/commands/compute/build_test.go +++ b/pkg/commands/compute/build_test.go @@ -901,6 +901,171 @@ func TestBuildCPP(t *testing.T) { } } +func TestBuildPython(t *testing.T) { + if os.Getenv("TEST_COMPUTE_BUILD_PYTHON") == "" && os.Getenv("TEST_COMPUTE_BUILD") == "" { + t.Log("skipping test") + t.Skip("Set TEST_COMPUTE_BUILD to run this test") + } + + args := testutil.SplitArgs + + scenarios := []struct { + name string + args []string + applicationConfig *config.File + fastlyManifest string + wantError string + wantRemediationError string + wantOutput []string + }{ + { + name: "no fastly.toml manifest", + args: args("compute build"), + wantError: "error reading fastly.toml: file not found", + wantRemediationError: "Run `fastly compute init` to ensure a correctly configured manifest.", + }, + // The following test validates that the project compiles successfully even + // though the fastly.toml manifest has no build script. There should be a + // default build script inserted. + // + // NOTE: This test passes --verbose so we can validate specific outputs. + { + name: "build script inserted dynamically when missing", + args: args("compute build --verbose"), + applicationConfig: &config.File{ + Language: config.Language{ + Python: config.Python{ + ToolchainConstraint: ">= 3.11", + UVConstraint: ">= 0.5.0", + }, + }, + }, + fastlyManifest: ` + manifest_version = 2 + name = "test" + language = "python"`, + wantOutput: []string{ + "No custom build script found in fastly.toml [scripts.build].", // requires --verbose + "Using default build command: uv run fastly-compute-py build", + "Built package", + }, + }, + { + name: "build error", + args: args("compute build"), + applicationConfig: &config.File{ + Language: config.Language{ + Python: config.Python{ + ToolchainConstraint: ">= 3.11", + UVConstraint: ">= 0.5.0", + }, + }, + }, + fastlyManifest: ` + manifest_version = 2 + name = "test" + language = "python" + + [scripts] + build = "exit 1"`, + wantRemediationError: compute.DefaultBuildErrorRemediation, + }, + // NOTE: This test passes --verbose so we can validate specific outputs. + { + name: "successful build", + args: args("compute build --verbose"), + applicationConfig: &config.File{ + Language: config.Language{ + Python: config.Python{ + ToolchainConstraint: ">= 3.11", + UVConstraint: ">= 0.5.0", + }, + }, + }, + fastlyManifest: fmt.Sprintf(` + manifest_version = 2 + name = "test" + language = "python" + + [scripts] + build = "%s"`, compute.PythonDefaultBuildCommand), + wantOutput: []string{ + "Creating ./bin directory (for Wasm binary)", + "Built package", + }, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + wasmtoolsBinName := "wasm-tools" + latestDownloaded := wasmtoolsBinName + "-latest-downloaded" + + // Create test environment + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Copy: []testutil.FileIO{ + {Src: filepath.Join("testdata", "build", "python", "main.py"), Dst: "main.py"}, + {Src: filepath.Join("testdata", "build", "python", "pyproject.toml"), Dst: "pyproject.toml"}, + {Src: filepath.Join("testdata", "build", "python", "uv.lock"), Dst: "uv.lock"}, + }, + Write: []testutil.FileIO{ + {Src: `#!/usr/bin/env bash + echo wasm-tools 1.0.4`, Dst: wasmtoolsBinName, Executable: true}, + {Src: `#!/usr/bin/env bash + echo wasm-tools 2.0.0`, Dst: latestDownloaded, Executable: true}, + {Src: testcase.fastlyManifest, Dst: manifest.Filename}, + }, + }) + defer os.RemoveAll(rootdir) + wasmtoolsBinPath := filepath.Join(rootdir, wasmtoolsBinName) + + // Before running the test, chdir into the build environment. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + var stdout threadsafe.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + if testcase.applicationConfig != nil { + opts.Config = *testcase.applicationConfig + } + opts.Versioners = global.Versioners{ + WasmTools: mock.AssetVersioner{ + AssetVersion: "1.2.3", + BinaryFilename: wasmtoolsBinName, + DownloadOK: true, + DownloadedFile: latestDownloaded, + InstallFilePath: wasmtoolsBinPath, + }, + } + return opts, nil + } + err = app.Run(testcase.args, nil) + + t.Log(stdout.String()) + + testutil.AssertRemediationErrorContains(t, err, testcase.wantRemediationError) + + if testcase.wantError != "" { + testutil.AssertErrorContains(t, err, testcase.wantError) + } + for _, s := range testcase.wantOutput { + testutil.AssertStringContains(t, stdout.String(), s) + } + }) + } +} + // NOTE: TestBuildOther also validates the post_build settings. func TestBuildOther(t *testing.T) { args := testutil.SplitArgs diff --git a/pkg/commands/compute/testdata/build/python/main.py b/pkg/commands/compute/testdata/build/python/main.py new file mode 100644 index 000000000..fe808989c --- /dev/null +++ b/pkg/commands/compute/testdata/build/python/main.py @@ -0,0 +1,11 @@ +from fastly_compute.wsgi import WsgiHttpIncoming + + +def app(environ, start_response): + status = "200 OK" + headers = [("Content-type", "text/plain; charset=utf-8")] + start_response(status, headers) + return [b"Hello, world!"] + + +HttpIncoming = WsgiHttpIncoming(app) diff --git a/pkg/commands/compute/testdata/build/python/pyproject.toml b/pkg/commands/compute/testdata/build/python/pyproject.toml new file mode 100644 index 000000000..428c10fd0 --- /dev/null +++ b/pkg/commands/compute/testdata/build/python/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "python-test" +version = "0.1.0" +description = "Fastly Compute Python Test" +requires-python = ">=3.12" +dependencies = [ + "fastly-compute==0.1.2", +] + +[tool.fastly-compute] +entry = "main" diff --git a/pkg/commands/compute/testdata/build/python/uv.lock b/pkg/commands/compute/testdata/build/python/uv.lock new file mode 100644 index 000000000..c1521f5d4 --- /dev/null +++ b/pkg/commands/compute/testdata/build/python/uv.lock @@ -0,0 +1,27 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "fastly-compute" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/42/a8888d6d2b29cd97efe921a07b4c73f101692bcb5f881caa216717c0328a/fastly_compute-0.1.2.tar.gz", hash = "sha256:0e0bc8dc04a74ff194332d11700776b2d9dfad2ec037871e899654c69d52d019", size = 114101, upload-time = "2026-06-03T20:23:12.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/07/2cc9d207d4a77c6fdccaa6db66a24a71d6fb5450c253a890c46cc45efcf0/fastly_compute-0.1.2-cp312-abi3-macosx_10_15_x86_64.whl", hash = "sha256:2a90f472d18f73bb20e548716f8d07dcee1b88bb9359b1a810d00aece687c71e", size = 17910850, upload-time = "2026-06-03T20:23:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1c/d90fd8095f145475d17b2173a2efae8edfb11dfc0cec26ccbe89999099e9/fastly_compute-0.1.2-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:260b89ddbae751acb9a235d6c62cafdd61c03a482a1312b21e44c7c746631cb4", size = 17029539, upload-time = "2026-06-03T20:23:03.354Z" }, + { url = "https://files.pythonhosted.org/packages/62/40/cc864be3c5c87c0fceeaf2c13943bc9b806e5a7dcaf14f4eb7e75b453fc7/fastly_compute-0.1.2-cp312-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:526b81667ef4daa2ab63fd6852e208b8228074a938eb34b2466492788e572359", size = 18258100, upload-time = "2026-06-03T20:23:05.726Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/d181e4aae7c77b6497df736d161c5759b29b2c5264fed27438663249d1de/fastly_compute-0.1.2-cp312-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:54d961dcec75c7d5b3617cf76b82d9a1b7658695c390edb76870a6cb5c738539", size = 18709208, upload-time = "2026-06-03T20:23:08.207Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/0b75c814fec3b32d022497283af909709e3477fdcc257e2d9784d0bf4de1/fastly_compute-0.1.2-cp312-abi3-win_amd64.whl", hash = "sha256:5e0cb6ecdb2b84b0936946e232a131851134ba7892e6ba796a296ec0d2aafff6", size = 16308551, upload-time = "2026-06-03T20:23:10.59Z" }, +] + +[[package]] +name = "python-test" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastly-compute" }, +] + +[package.metadata] +requires-dist = [{ name = "fastly-compute", specifier = "==0.1.2" }] From f3b8e2c1f814b023f38f6cff20d9298f79127e0f Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Fri, 5 Jun 2026 15:59:55 -0500 Subject: [PATCH 08/12] Install uv in CI for tests --- .github/workflows/pr_test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/pr_test.yml b/.github/workflows/pr_test.yml index b9f0ce70c..f8c077e09 100644 --- a/.github/workflows/pr_test.yml +++ b/.github/workflows/pr_test.yml @@ -124,6 +124,8 @@ jobs: uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ matrix.node-version }} + - name: "Install uv" + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: "Install WASI SDK" run: | wget -q "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_FULL_VERSION}-x86_64-linux.tar.gz" @@ -194,6 +196,8 @@ jobs: uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ matrix.node-version }} + - name: "Install uv" + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: "Install WASI SDK (Ubuntu)" if: matrix.platform == 'ubuntu-latest' run: | From 3bc678f4cb7fba00949662211b7fa2873e06f854 Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Tue, 4 Aug 2026 16:04:07 +0100 Subject: [PATCH 09/12] Move Python CHANGELOG entry to Unreleased The entry was under the already-released v15.2.0 section. Also aligns the PR link formatting with the surrounding entries. --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dace03b69..9197da2bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ ### Enhancements: +- feat(compute/init): add support for Python language ([#1811](https://github.com/fastly/cli/pull/1811)) + ### Dependencies: - build(deps): `golang.org/x/crypto` from 0.53.0 to 0.54.0 ([#1847](https://github.com/fastly/cli/pull/1847)) - build(deps): `github.com/bodgit/sevenzip` from 1.6.4 to 1.6.5 ([#1847](https://github.com/fastly/cli/pull/1847)) @@ -70,7 +72,6 @@ - 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: From c97db52a333c1a3f894f871234e2a4788f5dcb1b Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Tue, 4 Aug 2026 16:04:15 +0100 Subject: [PATCH 10/12] Drop redundant nil validator in starter kit URL prompt text.Input takes variadic validators, so omitting the argument is the way to skip validation. --- pkg/commands/compute/init.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/commands/compute/init.go b/pkg/commands/compute/init.go index 8d9828df0..9f0be5356 100644 --- a/pkg/commands/compute/init.go +++ b/pkg/commands/compute/init.go @@ -787,7 +787,7 @@ func (c *InitCommand) PromptForStarterKit(kits []config.StarterKit, in io.Reader 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) + option, err = text.Input(out, "Please paste a template git URL: ", in) if err != nil { return "", "", "", fmt.Errorf("error reading input: %w", err) } From d3601255acf823c986aa916633cb32ebfa8498cd Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Tue, 4 Aug 2026 16:04:23 +0100 Subject: [PATCH 11/12] Only merge package_info from build-tool Wasm metadata Seeding the whole DataCollection from the binary let a build tool's script_info, machine_info and build_info override fields the CLI owns. The CLI now always writes those itself and reads back only the package list, which is the one thing it cannot collect for Python. The read is also gated on the language reporting no dependencies of its own, so the extra `wasm-tools metadata show` subprocess no longer runs for Rust, Go and JavaScript. --- pkg/commands/compute/build.go | 29 ++++++++++--------- .../compute/build_metadata_internal_test.go | 26 +++++------------ 2 files changed, 23 insertions(+), 32 deletions(-) diff --git a/pkg/commands/compute/build.go b/pkg/commands/compute/build.go index ec0878707..6ffe51f2c 100644 --- a/pkg/commands/compute/build.go +++ b/pkg/commands/compute/build.go @@ -337,13 +337,7 @@ 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{ @@ -360,11 +354,17 @@ func (c *BuildCommand) AnnotateWasmBinaryLong(wasmtools string, args []string, l } } if metadata.PackageInfo == "enable" { - if dc.PackageInfo.Packages == nil { - dc.PackageInfo.Packages = language.Dependencies() + dc.PackageInfo = DataCollectionPackageInfo{ + ClonedFrom: c.Globals.Manifest.File.ClonedFrom, + Packages: language.Dependencies(), } - if dc.PackageInfo.ClonedFrom == "" { - dc.PackageInfo.ClonedFrom = c.Globals.Manifest.File.ClonedFrom + // Some build tools (e.g. Python's fastly-compute-py) resolve dependencies + // themselves and embed package_info directly, so the CLI has no way to + // collect them and must preserve what the build tool wrote. + if len(dc.PackageInfo.Packages) == 0 { + if existing := c.readExistingPackageInfo(wasmtools); existing != nil { + dc.PackageInfo.Packages = existing.Packages + } } } if metadata.ScriptInfo == "enable" { @@ -385,9 +385,10 @@ 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 { +// readExistingPackageInfo reads the package_info of any fastly_data already +// embedded in the Wasm binary by the build tool. Returns nil if absent or +// unparseable. +func (c *BuildCommand) readExistingPackageInfo(wasmtools string) *DataCollectionPackageInfo { // #nosec G204 -- wasmtools path comes from trusted CLI config out, err := exec.Command(wasmtools, "metadata", "show", "--json", binWasmPath).Output() if err != nil { @@ -437,7 +438,7 @@ func (c *BuildCommand) readExistingFastlyData(wasmtools string) *DataCollection if err := json.Unmarshal([]byte(val), &dc); err != nil { return nil } - return &dc + return &dc.PackageInfo } return nil } diff --git a/pkg/commands/compute/build_metadata_internal_test.go b/pkg/commands/compute/build_metadata_internal_test.go index e7e711e18..d6859e842 100644 --- a/pkg/commands/compute/build_metadata_internal_test.go +++ b/pkg/commands/compute/build_metadata_internal_test.go @@ -17,7 +17,7 @@ func mockWasmToolsScript(staticOutput string) string { return "#!/usr/bin/env bash\ncat << 'EOF'\n" + staticOutput + "\nEOF" } -func TestReadExistingFastlyData(t *testing.T) { +func TestReadExistingPackageInfo(t *testing.T) { // Create a temporary directory for our mock environment rootdir, err := os.MkdirTemp("", "fastly-metadata-test-*") if err != nil { @@ -49,30 +49,20 @@ func TestReadExistingFastlyData(t *testing.T) { scenarios := []struct { name string jsonOutput string - expectedData *DataCollection + expectedData *DataCollectionPackageInfo }{ { 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", - }, + expectedData: &DataCollectionPackageInfo{ + Packages: map[string]string{"foo": "1.0.0"}, }, }, { 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", - }, + expectedData: &DataCollectionPackageInfo{ + Packages: map[string]string{"bar": "2.0.0"}, }, }, { @@ -97,7 +87,7 @@ func TestReadExistingFastlyData(t *testing.T) { } cmd := &BuildCommand{} - actualData := cmd.readExistingFastlyData(wasmtoolsBin) + actualData := cmd.readExistingPackageInfo(wasmtoolsBin) if tc.expectedData == nil { if actualData != nil { @@ -107,7 +97,7 @@ func TestReadExistingFastlyData(t *testing.T) { } if actualData == nil { - t.Fatal("expected non-nil DataCollection, got nil") + t.Fatal("expected non-nil DataCollectionPackageInfo, got nil") } // Validate values From d54cbf213ce28a9bd329215c46c6d653167a9a0a Mon Sep 17 00:00:00 2001 From: Kailan Blanks Date: Tue, 4 Aug 2026 16:04:30 +0100 Subject: [PATCH 12/12] Error on missing Python toolchain_constraint Silently falling back to a hardcoded ">= 3.11" hid a broken or outdated CLI config and could validate against a constraint we never shipped. --- pkg/commands/compute/language_python.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/pkg/commands/compute/language_python.go b/pkg/commands/compute/language_python.go index 75ec9847c..0d91a7307 100644 --- a/pkg/commands/compute/language_python.go +++ b/pkg/commands/compute/language_python.go @@ -97,10 +97,9 @@ func (p *Python) DefaultBuildScript() bool { // 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. +// Returns empty because fastly-compute-py resolves the dependency set during +// the build and embeds it into the Wasm itself, which AnnotateWasmBinaryLong +// then reads back rather than overwriting. func (p *Python) Dependencies() map[string]string { return make(map[string]string) } @@ -161,7 +160,10 @@ func (p *Python) toolchainConstraint() error { func (p *Python) checkPythonVersion() error { requiredConstraint := p.config.ToolchainConstraint if requiredConstraint == "" { - requiredConstraint = ">= 3.11" + return fsterr.RemediationError{ + Inner: fmt.Errorf("no Python 'toolchain_constraint' found in the CLI config"), + Remediation: "Run `fastly config --reset` to restore a config compatible with the current CLI version.", + } } if p.verbose {