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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions module/sys/sys.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package sys
import (
"bufio"
"fmt"
"io"
"os"
"runtime"
"strings"
Expand All @@ -12,6 +13,7 @@ import (
"github.com/1set/starlet"
"github.com/1set/starlet/dataconv"
"go.starlark.net/starlark"
"golang.org/x/term"
)

const (
Expand All @@ -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 "<stdin lines>" }
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
Expand Down
66 changes: 66 additions & 0 deletions module/sys/sys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package sys
// - input() reads and trims a line from stdin

import (
"io"
"os"
"runtime"
"testing"
Expand All @@ -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", "<member>").
Expand Down Expand Up @@ -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) {
Expand Down
Loading