From 3a69fbce750b97a55f137559328e084b7e6adf1f Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Wed, 26 Aug 2026 23:30:12 -0400 Subject: [PATCH] feat(imports): generate executables from python scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `.py` to the script types that `imports:` can turn into executables, so a repo's Python scripts become named, discoverable flow executables alongside its shell ones. Python uses `#` line comments, so the existing `f:name=` / `f:verb=` metadata syntax and its parser work unchanged — the new parser is the shell one with a different extension. Metadata after a shebang is covered by a test, since a shebang is idiomatic in python scripts. The generated executable leaves `interpreter` unset: the `.py` extension already routes it, and setting the field would add noise to every generated definition. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01R328pa3FUUfga4gYah1iQi --- docs/guides/executables.md | 16 ++++- docs/guides/generated-config.md | 28 +++++++- internal/fileparser/fileparser.go | 2 + internal/fileparser/fileparser_test.go | 8 +++ internal/fileparser/py_file_parser.go | 42 ++++++++++++ internal/fileparser/py_file_parser_test.go | 75 ++++++++++++++++++++++ internal/fileparser/testdata/complex.py | 6 ++ internal/fileparser/testdata/params.py | 8 +++ internal/fileparser/testdata/shebang.py | 4 ++ internal/fileparser/testdata/simple.py | 3 + 10 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 internal/fileparser/py_file_parser.go create mode 100644 internal/fileparser/py_file_parser_test.go create mode 100644 internal/fileparser/testdata/complex.py create mode 100644 internal/fileparser/testdata/params.py create mode 100644 internal/fileparser/testdata/shebang.py create mode 100644 internal/fileparser/testdata/simple.py diff --git a/docs/guides/executables.md b/docs/guides/executables.md index f8e91602..822004d0 100644 --- a/docs/guides/executables.md +++ b/docs/guides/executables.md @@ -602,13 +602,14 @@ All imported executables are automatically tagged with `generated` and their fil #### **Script Files** -Script files (`.sh`, `.bat`, `.cmd`, `.ps1`) are imported as single executables with the script's filename as the name and `exec` as the default verb. Each script type is executed with its native interpreter: +Script files (`.sh`, `.bat`, `.cmd`, `.ps1`, `.py`) are imported as single executables with the script's filename as the name and `exec` as the default verb. Each script type is executed with its native interpreter: | Extension | Interpreter | Platforms | |-----------|------------|-----------| | `.sh` | Built-in POSIX shell | All (cross-platform) | | `.bat`, `.cmd` | `cmd.exe /C` | Windows | | `.ps1` | `pwsh` or `powershell` | All (requires PowerShell) | +| `.py` | Resolved Python (see [Running Python](#running-python)) | All (requires Python) | You can use special comments to override executable metadata. The comment syntax depends on the script type: @@ -644,6 +645,19 @@ kubectl apply -f k8s\ Write-Host "Deploying to production..." kubectl apply -f k8s/ ``` + +```python [Python (.py)] +#!/usr/bin/env python3 +# f:name=production f:verb=deploy +# f:description="Deploy to production environment" +# f:tag=production f:tag=critical +# f:timeout=10m + +import subprocess + +print("Deploying to production...") +subprocess.run(["kubectl", "apply", "-f", "k8s/"], check=True) +``` ::: See the [generated configuration reference](generated-config.md) for more details. diff --git a/docs/guides/generated-config.md b/docs/guides/generated-config.md index 7900715c..eae77293 100644 --- a/docs/guides/generated-config.md +++ b/docs/guides/generated-config.md @@ -5,12 +5,12 @@ title: Imported Executables Config Reference # Imported Executables Config Reference flow can automatically generate executables from scripts and Makefiles using special comments. -Supported script types include shell scripts (`.sh`), batch files (`.bat`, `.cmd`), and PowerShell scripts (`.ps1`). +Supported script types include shell scripts (`.sh`), batch files (`.bat`, `.cmd`), PowerShell scripts (`.ps1`), and Python scripts (`.py`). flow parses these comments during workspace synchronization and creates executable definitions that can be run like any other flow executable. See [Importing Executables](executables.md#importing-executables) for more details. > [!NOTE] The configuration comments must be at the top of the script file or right above the Makefile target definition. -> - **Shell / PowerShell:** Use `# ` as the comment prefix (e.g., `# f:name=deploy`) +> - **Shell / PowerShell / Python:** Use `# ` as the comment prefix (e.g., `# f:name=deploy`) > - **Batch files:** Use `REM ` or `:: ` as the comment prefix (e.g., `REM f:name=deploy`) ## Supported Fields @@ -54,6 +54,16 @@ echo Deploying to %ENV_NAME% with token: %API_TOKEN%... Write-Host "Deploying to $env:ENV_NAME with token: $($env:API_TOKEN.Substring(0,8))..." ``` + +```python [Python (.py)] +#!/usr/bin/env python3 +# f:name=deploy-with-secrets f:verb=deploy +# f:params=secretRef:api-key:API_TOKEN|prompt:Environment?:ENV_NAME|text:production:DEFAULT_ENV + +import os + +print(f"Deploying to {os.environ['ENV_NAME']} with token: {os.environ['API_TOKEN'][:8]}...") +``` ::: **Parameter Types:** @@ -100,6 +110,20 @@ if ($env:DRY_RUN -eq "true") { Write-Host "Building version $env:VERSION" } ``` + +```python [Python (.py)] +#!/usr/bin/env python3 +# f:name=build-app f:verb=build +# f:args=flag:dry-run:DRY_RUN|pos:1:VERSION|flag:verbose:VERBOSE + +import os + +version = os.environ["VERSION"] +if os.environ.get("DRY_RUN") == "true": + print(f"DRY RUN: Would build version {version}") +else: + print(f"Building version {version}") +``` ::: **Argument Types:** diff --git a/internal/fileparser/fileparser.go b/internal/fileparser/fileparser.go index f3be35c7..96c29d2c 100644 --- a/internal/fileparser/fileparser.go +++ b/internal/fileparser/fileparser.go @@ -77,6 +77,8 @@ func parseScriptFile(wsPath, fn, expandedFile string) (executable.ExecutableList exec, err = ExecutablesFromBatFile(wsPath, expandedFile) case ".ps1": exec, err = ExecutablesFromPs1File(wsPath, expandedFile) + case ".py": + exec, err = ExecutablesFromPyFile(wsPath, expandedFile) default: logger.Log().Warn("unable to import executables - unsupported file type", "file", fn) return nil, nil diff --git a/internal/fileparser/fileparser_test.go b/internal/fileparser/fileparser_test.go index ec5cdfcf..0bf68800 100644 --- a/internal/fileparser/fileparser_test.go +++ b/internal/fileparser/fileparser_test.go @@ -75,6 +75,14 @@ var _ = Describe("ExecutablesFromImports", func() { Expect(result[0].Exec.File).To(Equal("simple.ps1")) }) + It("should return executables from py file imports", func() { + flowFile.Imports = append(flowFile.Imports, "simple.py") + result, err := fileparser.ExecutablesFromImports("ws", flowFile) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Exec.File).To(Equal("simple.py")) + }) + It("should log a warning for invalid file type", func() { mockLogger.EXPECT().Warn(gomock.Any(), "file", "invalidfile").AnyTimes() flowFile.Imports = append(flowFile.Imports, "invalidfile") diff --git a/internal/fileparser/py_file_parser.go b/internal/fileparser/py_file_parser.go new file mode 100644 index 00000000..90bf004c --- /dev/null +++ b/internal/fileparser/py_file_parser.go @@ -0,0 +1,42 @@ +package fileparser + +import ( + "os" + "path/filepath" + + "github.com/flowexec/flow/v2/types/executable" +) + +func ExecutablesFromPyFile(wsPath, filePath string) (*executable.Executable, error) { + fn := filepath.Base(filePath) + verb := InferVerb(fn) + execName := NormalizeName(fn, verb.String()) + dir := executable.Directory(shortenWsPath(wsPath, filepath.Dir(filePath))) + exec := &executable.Executable{ + Verb: verb, + Name: execName, + Exec: &executable.ExecExecutableType{ + Dir: dir, + File: filepath.Base(filePath), + }, + } + + fileBytes, err := os.ReadFile(filepath.Clean(filePath)) + if err != nil { + return nil, err + } + + // Python uses # for line comments, same as shell scripts, so the metadata + // comment syntax is identical. The interpreter is left unset: the .py + // extension already implies it, and setting it would only add noise. + cfg, err := ExtractExecConfig(string(fileBytes), "# ") + if err != nil { + return nil, err + } + if err := ApplyExecConfig(exec, cfg); err != nil { + return nil, err + } + + exec.Tags = append(exec.Tags, generatedTag) + return exec, nil +} diff --git a/internal/fileparser/py_file_parser_test.go b/internal/fileparser/py_file_parser_test.go new file mode 100644 index 00000000..36e49d1c --- /dev/null +++ b/internal/fileparser/py_file_parser_test.go @@ -0,0 +1,75 @@ +package fileparser_test + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flowexec/flow/v2/internal/fileparser" + "github.com/flowexec/flow/v2/types/executable" +) + +var _ = Describe("ExecutablesFromPyFile", func() { + const wsPath = "testdata" + + It("should parse a simple py file", func() { + exec, err := fileparser.ExecutablesFromPyFile(wsPath, "testdata/simple.py") + Expect(err).NotTo(HaveOccurred()) + Expect(exec).NotTo(BeNil()) + Expect(exec.Name).To(Equal("hello")) + Expect(exec.Verb).To(Equal(executable.VerbShow)) + Expect(exec.Exec).NotTo(BeNil()) + Expect(exec.Exec.File).To(Equal("simple.py")) + Expect(exec.Exec.Dir).To(Equal(executable.Directory("//"))) + Expect(exec.Tags).To(ContainElement("generated")) + }) + + It("should leave the interpreter unset, since .py already implies it", func() { + exec, err := fileparser.ExecutablesFromPyFile(wsPath, "testdata/simple.py") + Expect(err).NotTo(HaveOccurred()) + Expect(exec.Exec.InterpreterIsSet()).To(BeFalse()) + // The extension is what routes it, so the executable still runs as python. + Expect(exec.Exec.InterpreterForFile()).To(Equal(executable.InterpreterPython)) + }) + + It("should parse a complex py file with all metadata", func() { + exec, err := fileparser.ExecutablesFromPyFile(wsPath, "testdata/complex.py") + Expect(err).NotTo(HaveOccurred()) + Expect(exec).NotTo(BeNil()) + Expect(exec.Name).To(Equal("deploy")) + Expect(exec.Verb).To(Equal(executable.VerbDeploy)) + Expect(exec.Description).To(Equal("Deploy to production")) + Expect(exec.Tags).To(ContainElements("production", "critical", "generated")) + expectedTimeout := 10 * time.Minute + Expect(exec.Timeout).To(Equal(&expectedTimeout)) + }) + + It("should parse params from a py file", func() { + exec, err := fileparser.ExecutablesFromPyFile(wsPath, "testdata/params.py") + Expect(err).NotTo(HaveOccurred()) + Expect(exec).NotTo(BeNil()) + Expect(exec.Name).To(Equal("test-params")) + Expect(exec.Exec.Params).To(HaveLen(3)) + Expect(exec.Exec.Params[0].SecretRef).To(Equal("my-secret")) + Expect(exec.Exec.Params[0].EnvKey).To(Equal("SECRET_VAR")) + Expect(exec.Exec.Params[1].Prompt).To(Equal("Enter name")) + Expect(exec.Exec.Params[1].EnvKey).To(Equal("NAME_VAR")) + Expect(exec.Exec.Params[2].Text).To(Equal("default-value")) + Expect(exec.Exec.Params[2].EnvKey).To(Equal("DEFAULT_VAR")) + }) + + It("should read metadata that follows a shebang line", func() { + // A shebang is idiomatic in python scripts, so it must not shadow the + // metadata comments beneath it. + exec, err := fileparser.ExecutablesFromPyFile(wsPath, "testdata/shebang.py") + Expect(err).NotTo(HaveOccurred()) + Expect(exec.Name).To(Equal("with-shebang")) + Expect(exec.Verb).To(Equal(executable.VerbRun)) + }) + + It("should error on a missing file", func() { + _, err := fileparser.ExecutablesFromPyFile(wsPath, "testdata/does-not-exist.py") + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/internal/fileparser/testdata/complex.py b/internal/fileparser/testdata/complex.py new file mode 100644 index 00000000..13ba011a --- /dev/null +++ b/internal/fileparser/testdata/complex.py @@ -0,0 +1,6 @@ +# f:name=deploy f:verb=deploy +# f:description="Deploy to production" +# f:tag=production f:tag=critical +# f:timeout=10m + +print("Deploying...") diff --git a/internal/fileparser/testdata/params.py b/internal/fileparser/testdata/params.py new file mode 100644 index 00000000..a99374fc --- /dev/null +++ b/internal/fileparser/testdata/params.py @@ -0,0 +1,8 @@ +# f:name=test-params f:verb=test +# f:params=secretRef:my-secret:SECRET_VAR|prompt:"Enter name":NAME_VAR|text:default-value:DEFAULT_VAR + +import os + +print("Secret:", os.environ["SECRET_VAR"]) +print("Name:", os.environ["NAME_VAR"]) +print("Default:", os.environ["DEFAULT_VAR"]) diff --git a/internal/fileparser/testdata/shebang.py b/internal/fileparser/testdata/shebang.py new file mode 100644 index 00000000..c3f5fe05 --- /dev/null +++ b/internal/fileparser/testdata/shebang.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 +# f:name=with-shebang f:verb=run + +print("shebang tolerated") diff --git a/internal/fileparser/testdata/simple.py b/internal/fileparser/testdata/simple.py new file mode 100644 index 00000000..b7a833f2 --- /dev/null +++ b/internal/fileparser/testdata/simple.py @@ -0,0 +1,3 @@ +# f:name=hello f:verb=show + +print("Hello, world!")