Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .execs/scripts/test-python.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Integration check for flow's python interpreter support.

Run by the `test python-script` executable in CI on Linux, macOS, and Windows.
It asserts the things flow itself is responsible for - that a real interpreter
ran, that flow-resolved parameters arrived in the environment, and that the
python env defaults were applied - so a regression in interpreter resolution
fails CI rather than silently degrading to shell execution.
"""

import os
import platform
import sys

print("Running python file execution test...")
print(f"OS: {platform.system()}")
print(f"Python: {sys.version.splitlines()[0]}")
print(f"Interpreter: {sys.executable}")

failures = []

# A shell interpreter could never have reached this file at all, but assert the
# version explicitly so a python2 interpreter is caught rather than tolerated.
if sys.version_info[0] != 3:
failures.append(f"expected python 3, got {sys.version_info[0]}")

# flow injects these so output streams live and workspaces stay free of
# __pycache__; see internal/services/run/python.go pythonEnv.
for key in ("PYTHONUNBUFFERED", "PYTHONDONTWRITEBYTECODE"):
if os.environ.get(key) != "1":
failures.append(f"{key} not set by flow (got {os.environ.get(key)!r})")

# Set by env.DefaultEnv for every run; interpreter resolution relies on it to
# find a workspace's .venv.
if not os.environ.get("FLOW_WORKSPACE_PATH"):
failures.append("FLOW_WORKSPACE_PATH missing from the run environment")

if failures:
for failure in failures:
print(f"FAIL: {failure}", file=sys.stderr)
sys.exit(1)

print("Python file execution works correctly.")
8 changes: 8 additions & 0 deletions .execs/test.flow
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ executables:
exit 1
fi

- verb: test
name: python-script
description: Test .py file execution via the resolved python interpreter
tags: [python]
exec:
dir: //
file: .execs/scripts/test-python.py

- verb: test
name: unit
description: Run unit tests with coverage
Expand Down
34 changes: 34 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ jobs:
uses: actions/setup-go@v7
with:
go-version: "1.25.x"
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.13"
# Build the flow under review and run every task with it, so CI exercises
# the code as it would land on main rather than the released CLI.
- name: Build flow
Expand Down Expand Up @@ -75,6 +79,10 @@ jobs:
uses: actions/setup-go@v7
with:
go-version: "1.25.x"
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.13"
# Build the flow under review and run every task with it, so CI exercises
# the code as it would land on main rather than the released CLI.
- name: Build flow
Expand Down Expand Up @@ -181,6 +189,32 @@ jobs:
flow-version: 'main'
timeout: '5m'

python-scripts:
name: "Test / python script execution"
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: "1.25.x"
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.13"
# Run this one directly rather than through the action: it exercises flow's
# own interpreter resolution, so the binary under test must be the one that
# runs, with no install path in between.
- name: Build flow
run: go build -o ./bin/flow .
- name: Run .py file execution test
run: ./bin/flow test python-script
env:
DISABLE_FLOW_INTERACTIVE: "true"

security:
runs-on: ubuntu-latest
steps:
Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/windows-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ jobs:
with:
go-version: "1.25.x"
cache: true
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.13"
# Build the flow under review and run every task with it, so CI exercises
# the code as it would land on main rather than the released CLI.
- name: Build flow
Expand Down Expand Up @@ -60,6 +64,10 @@ jobs:
with:
go-version: "1.25.x"
cache: true
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.13"
# Build the flow under review and run every task with it, so CI exercises
# the code as it would land on main rather than the released CLI.
- name: Build flow
Expand Down Expand Up @@ -153,6 +161,10 @@ jobs:
with:
go-version: "1.25.x"
cache: true
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.13"
# Build the flow under review and run every task with it, so CI exercises
# the code as it would land on main rather than the released CLI.
- name: Build flow
Expand All @@ -166,6 +178,13 @@ jobs:
flow-binary: ./bin/flow.exe
# Fallback for action releases predating flow-binary; drop once v1 includes it.
flow-version: "main"
# Run this one directly rather than through the action: it exercises flow's
# own interpreter resolution, so the binary under test must be the one that
# runs, with no install path in between.
- name: Run .py file execution test
run: ./bin/flow.exe test python-script
env:
DISABLE_FLOW_INTERACTIVE: "true"

windows-validation-complete:
name: "Windows validation complete"
Expand Down
51 changes: 51 additions & 0 deletions docs/guides/executables.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,60 @@ executables:
**Options:**
- `cmd`: Inline command to run
- `file`: Script file to execute
- `interpreter`: Which interpreter runs `cmd` — `sh` (default) or `python` (see below)
- `logMode`: How to format command output
- `container`: Run the command or file inside a container image (see below)

#### Running Python

Set `exec.interpreter` to `python` to run `cmd` as a Python script instead of a shell command:

```yaml
executables:
- verb: run
name: report
exec:
interpreter: python
cmd: |
import json, sys
print(json.dumps({"python": sys.version_info[:2]}))
```

Parameters, arguments, and secrets reach the script through the environment exactly as they do for
a shell command, so `os.environ` is how you read them.

A `.py` file needs no `interpreter` at all — the extension implies it:

```yaml
exec:
file: scripts/analyze.py
```

Setting `interpreter` explicitly overrides whatever the extension would have implied.

**How flow finds Python.** A project's virtualenv wins over bare system Python, so a script gets
the dependencies its repository installed:

| Order | Source |
|-------|--------|
| 1 | `FLOW_PYTHON_BIN`, if set |
| 2 | `$VIRTUAL_ENV` — an activated virtualenv |
| 3 | `<workspace root>/.venv` |
| 4 | `python3` on the `PATH` |
| 5 | `python` on the `PATH` |

`FLOW_PYTHON_BIN` is an environment variable rather than a field, so you can pin an interpreter for
a whole workspace in its `.env` file, or for one executable via `params`. If it is set but does not
resolve, the run fails rather than silently falling back to a different interpreter.

On Windows the virtualenv path is `Scripts\python.exe`, and `python` is preferred over `python3` —
`python3.exe` there is usually the Microsoft Store alias stub rather than a real interpreter.

flow runs `cmd` from a temporary file rather than `python -c`, which keeps your code out of the
process table and means tracebacks carry real line numbers. It also sets `PYTHONUNBUFFERED=1` so
output streams as it is produced, and `PYTHONDONTWRITEBYTECODE=1` to keep `__pycache__` out of your
workspace. Set either variable yourself to override.

#### Running in a container

Set `exec.container` to run the command inside a container instead of on the host. This gives you a
Expand Down
14 changes: 13 additions & 1 deletion docs/public/schemas/flowfile_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -242,10 +242,14 @@
"default": ""
},
"file": {
"description": "The file to execute (`.sh`, `.bat`, `.cmd`, `.ps1`).\nOnly one of `cmd` or `file` must be set.\n",
"description": "The file to execute (`.sh`, `.bat`, `.cmd`, `.ps1`, `.py`).\nOnly one of `cmd` or `file` must be set.\n",
"type": "string",
"default": ""
},
"interpreter": {
"$ref": "#/definitions/ExecutableExecInterpreter",
"description": "The interpreter used to run `cmd`. Defaults to `sh`, flow's built-in POSIX\nshell interpreter; `python` runs `cmd` as a Python script.\n\nWhen set, this also overrides the interpreter inferred from a `file`\nextension. When unset, `file` is run by the interpreter matching its\nextension, so `file: script.py` runs under Python without setting this.\n"
},
"logMode": {
"description": "The log mode to use when running the executable.\nThis can either be `hidden`, `json`, `logfmt` or `text`\n",
"type": "string",
Expand All @@ -256,6 +260,14 @@
}
}
},
"ExecutableExecInterpreter": {
"description": "The interpreter used to run a command.\n`sh` uses flow's built-in POSIX shell interpreter; `python` runs the command\nas a Python script.\n",
"type": "string",
"enum": [
"sh",
"python"
]
},
"ExecutableLaunchExecutableType": {
"description": "Launches an application or opens a URI.",
"type": "object",
Expand Down
18 changes: 17 additions & 1 deletion docs/types/flowfile.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,26 @@ Standard executable type. Runs a command/file in a subprocess.
| `cmd` | The command to execute. Only one of `cmd` or `file` must be set. | `string` | | |
| `container` | | [ExecutableExecContainer](#executableexeccontainer) | | |
| `dir` | | [ExecutableDirectory](#executabledirectory) | | |
| `file` | The file to execute (`.sh`, `.bat`, `.cmd`, `.ps1`). Only one of `cmd` or `file` must be set. | `string` | | |
| `file` | The file to execute (`.sh`, `.bat`, `.cmd`, `.ps1`, `.py`). Only one of `cmd` or `file` must be set. | `string` | | |
| `interpreter` | The interpreter used to run `cmd`. Defaults to `sh`, flow's built-in POSIX shell interpreter; `python` runs `cmd` as a Python script. When set, this also overrides the interpreter inferred from a `file` extension. When unset, `file` is run by the interpreter matching its extension, so `file: script.py` runs under Python without setting this. | [ExecutableExecInterpreter](#executableexecinterpreter) | | |
| `logMode` | The log mode to use when running the executable. This can either be `hidden`, `json`, `logfmt` or `text` | `string` | logfmt | |
| `params` | | [ExecutableParameterList](#executableparameterlist) | | |

### ExecutableExecInterpreter

The interpreter used to run a command.
`sh` uses flow's built-in POSIX shell interpreter; `python` runs the command
as a Python script.


**Type:** `string`

**Valid values:**
- `sh`
- `python`



### ExecutableLaunchExecutableType

Launches an application or opens a URI.
Expand Down
18 changes: 16 additions & 2 deletions internal/io/executable/detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ func executableDetailOpts(exec *executable.Executable) views.DetailContentOpts {
func execTypeName(exec *executable.Executable) string {
switch {
case exec.Exec != nil:
if exec.Exec.InterpreterForFile() == executable.InterpreterPython {
return "Python Executable"
}
return "Shell Executable"
case exec.Launch != nil:
return "Launch Executable"
Expand Down Expand Up @@ -143,12 +146,23 @@ func shellExecConfig(e *executable.ExecutableEnvironment, s *executable.ExecExec
if s == nil {
return ""
}
md := "## Shell Configuration\n"
lang, label := "sh", "Shell"
if s.ResolveInterpreter() == executable.InterpreterPython {
lang = "python"
}
if s.InterpreterForFile() == executable.InterpreterPython {
label = "Python"
}

md := fmt.Sprintf("## %s Configuration\n", label)
if s.LogMode != "" {
md += fmt.Sprintf("**Log Mode:** %s\n\n", s.LogMode)
}
if s.InterpreterIsSet() {
md += fmt.Sprintf("**Interpreter:** %s\n\n", s.ResolveInterpreter())
}
if s.Cmd != "" {
md += fmt.Sprintf("**Command**\n```sh\n%s\n```\n", s.Cmd)
md += fmt.Sprintf("**Command**\n```%s\n%s\n```\n", lang, s.Cmd)
} else if s.File != "" {
md += fmt.Sprintf("**File:** `%s`\n\n", s.File)
}
Expand Down
77 changes: 77 additions & 0 deletions internal/io/executable/detail_interpreter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package executable_test

import (
"strings"
"testing"

io "github.com/flowexec/flow/v2/internal/io/executable"
"github.com/flowexec/flow/v2/types/executable"
)

func interp(v executable.ExecInterpreter) *executable.ExecInterpreter { return &v }

// The browse views are how an interpreter is discovered without opening the
// flowfile, so each surface is pinned: the library subtitle, the detail heading,
// the interpreter line, and the fence language on the command block.
func TestBrowseSurfacesInterpreter(t *testing.T) {
tests := []struct {
name string
exec *executable.ExecExecutableType
wantSubtitle string
wantContains []string
wantMissing []string
}{
{
name: "shell command",
exec: &executable.ExecExecutableType{Cmd: "echo hi"},
wantSubtitle: "Shell Executable",
wantContains: []string{"## Shell Configuration", "```sh"},
wantMissing: []string{"**Interpreter:**", "```python"},
},
{
name: "python command",
exec: &executable.ExecExecutableType{Cmd: "print('hi')", Interpreter: interp(executable.InterpreterPython)},
wantSubtitle: "Python Executable",
wantContains: []string{"## Python Configuration", "**Interpreter:** python", "```python"},
wantMissing: []string{"```sh"},
},
{
// The extension already says python, so the heading follows it while
// the interpreter line stays out - nothing was configured to report.
name: "py file without an explicit interpreter",
exec: &executable.ExecExecutableType{File: "report.py"},
wantSubtitle: "Python Executable",
wantContains: []string{"## Python Configuration", "report.py"},
wantMissing: []string{"**Interpreter:**"},
},
{
name: "explicit sh overrides a py extension",
exec: &executable.ExecExecutableType{File: "report.py", Interpreter: interp(executable.InterpreterSh)},
wantSubtitle: "Shell Executable",
wantContains: []string{"## Shell Configuration"},
wantMissing: []string{"## Python Configuration"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := &executable.Executable{Verb: "run", Name: "probe", Exec: tt.exec}

if got := io.ExecTypeNameForTest(e); got != tt.wantSubtitle {
t.Errorf("execTypeName() = %q, want %q", got, tt.wantSubtitle)
}

body := io.ExecBodyMarkdownForTest(e)
for _, want := range tt.wantContains {
if !strings.Contains(body, want) {
t.Errorf("detail body missing %q:\n%s", want, body)
}
}
for _, missing := range tt.wantMissing {
if strings.Contains(body, missing) {
t.Errorf("detail body unexpectedly contains %q:\n%s", missing, body)
}
}
})
}
}
Loading
Loading