From 05c48f22620859ba77ccec4b25ebda079874651d Mon Sep 17 00:00:00 2001 From: Kevin Tang <73975146+vt128@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:41:45 +0800 Subject: [PATCH] [feat] CLI-02/C-3: stdin/pipe reading on the sys module (STAR-19) Let scripts consume piped DATA from standard input (the script itself still comes from a file or -c). Three additions to the sys module, mirroring Python's sys.stdin: - sys.read() -> all of stdin as a string. - sys.lines() -> a LAZY iterable over stdin lines (trailing CR/LF trimmed), so a large stream is consumed line by line, not buffered whole. One-shot: the stream drains as it iterates, like 'for line in sys.stdin'. - sys.isatty() -> whether stdin is a terminal vs a pipe/file, so a script can branch on interactive vs piped input. No new module or capability: sys is already wired and classified CapProcess. Tests redirect os.Stdin through a pipe to cover read/lines (incl. a final line with no newline)/isatty. README documents it. Coverage 75.8%; Docker golang:1.22 green. --- README.md | 19 +++++++++++ module/sys/sys.go | 75 ++++++++++++++++++++++++++++++++++++++++++ module/sys/sys_test.go | 66 +++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) diff --git a/README.md b/README.md index f615761..3603033 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,25 @@ $ ./starcli greet.star -- --name Kevin --count 3 --shout in.txt Kevin 3 True in.txt ``` +#### Read Piped Input + +The `sys` module reads piped **data** from standard input (the script itself +still comes from a file or `-c`). `sys.read()` returns all of stdin; `sys.lines()` +is a **lazy** iterator (a large stream is not buffered whole); `sys.isatty()` +tells interactive from piped input. + +```python +load("sys", "lines") +for line in lines(): + print(line.upper()) +``` + +```bash +$ printf 'foo\nbar\n' | ./starcli upper.star +FOO +BAR +``` + #### Check Without Running Syntax- and resolve-check a script without executing it (reports problems as diff --git a/module/sys/sys.go b/module/sys/sys.go index fec6852..c41fb16 100644 --- a/module/sys/sys.go +++ b/module/sys/sys.go @@ -4,6 +4,7 @@ package sys import ( "bufio" "fmt" + "io" "os" "runtime" "strings" @@ -12,6 +13,7 @@ import ( "github.com/1set/starlet" "github.com/1set/starlet/dataconv" "go.starlark.net/starlark" + "golang.org/x/term" ) const ( @@ -34,10 +36,83 @@ func NewModule(args []string) starlet.ModuleLoader { "argv": starlark.NewList(sa), "input": starlark.NewBuiltin(ModuleName+".input", rawStdInput), "host": starlark.String(config.GetHostname()), + "read": starlark.NewBuiltin(ModuleName+".read", stdinRead), + "lines": starlark.NewBuiltin(ModuleName+".lines", stdinLinesFn), + "isatty": starlark.NewBuiltin(ModuleName+".isatty", stdinIsatty), } return dataconv.WrapModuleData(ModuleName, sd) } +// stdinRead reads all of standard input (piped data) and returns it as a string. +func stdinRead(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + if err := starlark.UnpackArgs(b.Name(), args, kwargs); err != nil { + return nil, err + } + data, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, err + } + return starlark.String(data), nil +} + +// stdinIsatty reports whether standard input is a terminal (vs a pipe or file), +// so a script can branch on interactive vs piped input. +func stdinIsatty(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + if err := starlark.UnpackArgs(b.Name(), args, kwargs); err != nil { + return nil, err + } + return starlark.Bool(term.IsTerminal(int(os.Stdin.Fd()))), nil +} + +// stdinLinesFn returns a lazy iterable over the lines of standard input, so a +// large stream is consumed line by line rather than buffered whole. +func stdinLinesFn(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + if err := starlark.UnpackArgs(b.Name(), args, kwargs); err != nil { + return nil, err + } + return stdinLines{}, nil +} + +// stdinLines is a one-shot lazy iterable over os.Stdin lines (trailing CR/LF +// trimmed). Iterating it more than once yields nothing the second time, as the +// stream is already drained — the same single-stream contract as `for line in +// sys.stdin` in Python. +type stdinLines struct{} + +var _ starlark.Iterable = stdinLines{} + +func (stdinLines) String() string { return "" } +func (stdinLines) Type() string { return "stdin_lines" } +func (stdinLines) Freeze() {} +func (stdinLines) Truth() starlark.Bool { return starlark.True } +func (stdinLines) Hash() (uint32, error) { return 0, fmt.Errorf("unhashable type: stdin_lines") } +func (stdinLines) Iterate() starlark.Iterator { + return &stdinLinesIter{r: bufio.NewReader(os.Stdin)} +} + +type stdinLinesIter struct { + r *bufio.Reader + done bool +} + +func (it *stdinLinesIter) Next(p *starlark.Value) bool { + if it.done { + return false + } + line, err := it.r.ReadString('\n') + if line == "" && err != nil { + it.done = true + return false + } + *p = starlark.String(strings.TrimRight(line, "\r\n")) + if err != nil { + it.done = true // final line had no trailing newline + } + return true +} + +func (it *stdinLinesIter) Done() {} + func rawStdInput(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { // unpack arguments var prompt string diff --git a/module/sys/sys_test.go b/module/sys/sys_test.go index 21e2ca9..49e7f1c 100644 --- a/module/sys/sys_test.go +++ b/module/sys/sys_test.go @@ -7,6 +7,7 @@ package sys // - input() reads and trims a line from stdin import ( + "io" "os" "runtime" "testing" @@ -15,6 +16,65 @@ import ( "go.starlark.net/starlarkstruct" ) +// withStdin redirects os.Stdin to a pipe carrying content for the duration of f. +func withStdin(t *testing.T, content string, f func()) { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + orig := os.Stdin + os.Stdin = r + defer func() { os.Stdin = orig }() + go func() { _, _ = io.WriteString(w, content); _ = w.Close() }() + f() +} + +func TestStdinRead(t *testing.T) { + withStdin(t, "a\nb\nc\n", func() { + v, err := stdinRead(nil, starlark.NewBuiltin("sys.read", stdinRead), nil, nil) + if err != nil { + t.Fatalf("read: %v", err) + } + if v != starlark.String("a\nb\nc\n") { + t.Errorf("read()=%q want %q", v, "a\nb\nc\n") + } + }) +} + +func TestStdinLines(t *testing.T) { + withStdin(t, "foo\nbar\nbaz", func() { // no trailing newline on the last line + it := stdinLines{}.Iterate() + defer it.Done() + var got []string + var v starlark.Value + for it.Next(&v) { + got = append(got, string(v.(starlark.String))) + } + want := []string{"foo", "bar", "baz"} + if len(got) != len(want) { + t.Fatalf("lines()=%v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("line %d=%q want %q", i, got[i], want[i]) + } + } + }) +} + +func TestStdinIsatty(t *testing.T) { + withStdin(t, "data", func() { + v, err := stdinIsatty(nil, starlark.NewBuiltin("sys.isatty", stdinIsatty), nil, nil) + if err != nil { + t.Fatalf("isatty: %v", err) + } + if v != starlark.False { // a pipe is not a terminal + t.Errorf("isatty()=%v want False on a pipe", v) + } + }) +} + func TestNewModule_Surface(t *testing.T) { // WrapModuleData returns {ModuleName: *starlarkstruct.Module{Members: ...}}; // the members are what scripts reach via load("sys", ""). @@ -57,6 +117,12 @@ func TestNewModule_Surface(t *testing.T) { if fn.Name() != "sys.input" { t.Errorf("input builtin name=%q want %q", fn.Name(), "sys.input") } + + for _, name := range []string{"read", "lines", "isatty"} { + if _, ok := m[name].(*starlark.Builtin); !ok { + t.Errorf("member %q is %T, want *starlark.Builtin", name, m[name]) + } + } } func TestInput_ReadsAndTrimsLine(t *testing.T) {