From 79bb9256ae58ef20f65fd6909672a4c7bd008525 Mon Sep 17 00:00:00 2001 From: Sebastien Binet Date: Fri, 7 Mar 2025 11:07:32 +0100 Subject: [PATCH 01/13] ci: update GitHub actions Signed-off-by: Sebastien Binet --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49657da8..86a5f633 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Install Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} @@ -34,12 +34,12 @@ jobs: git config --global core.eol lf - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: fetch-depth: 1 - name: Cache-Go - uses: actions/cache@v1 + uses: actions/cache@v4 with: # In order: # * Module download cache @@ -93,11 +93,11 @@ jobs: run: | go run ./ci/run-tests.go $TAGS -race - name: static-check - uses: dominikh/staticcheck-action@v1.2.0 + uses: dominikh/staticcheck-action@v1 with: install-go: false cache-key: ${{ matrix.platform }} version: "2022.1" - name: Upload-Coverage if: matrix.platform == 'ubuntu-latest' - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 From a0c052992576917aac53235396b525632e16f9ec Mon Sep 17 00:00:00 2001 From: AN Long Date: Sat, 28 Jun 2025 00:18:04 +0900 Subject: [PATCH 02/13] py: implement str.join Fixes #232 --- py/string.go | 27 +++++++++++++++++++++++++++ py/tests/string.py | 9 +++++++++ 2 files changed, 36 insertions(+) diff --git a/py/string.go b/py/string.go index e470c01d..a987a11a 100644 --- a/py/string.go +++ b/py/string.go @@ -226,6 +226,9 @@ replaced.`) return self.(String).Lower() }, 0, "lower() -> a copy of the string converted to lowercase") + StringType.Dict["join"] = MustNewMethod("join", func(self Object, args Tuple) (Object, error) { + return self.(String).Join(args) + }, 0, "join(iterable) -> return a string which is the concatenation of the strings in iterable") } // Type of this object @@ -755,6 +758,30 @@ func (s String) Lower() (Object, error) { return String(strings.ToLower(string(s))), nil } +func (s String) Join(args Tuple) (Object, error) { + if len(args) != 1 { + return nil, ExceptionNewf(TypeError, "join() takes exactly one argument (%d given)", len(args)) + } + var parts []string + iterable, err := Iter(args[0]) + if err != nil { + return nil, err + } + item, err := Next(iterable) + for err == nil { + str, ok := item.(String) + if !ok { + return nil, ExceptionNewf(TypeError, "sequence item %d: expected str instance, %s found", len(parts), item.Type().Name) + } + parts = append(parts, string(str)) + item, err = Next(iterable) + } + if err != StopIteration { + return nil, err + } + return String(strings.Join(parts, string(s))), nil +} + // Check stringerface is satisfied var ( _ richComparison = String("") diff --git a/py/tests/string.py b/py/tests/string.py index f2ad6e9b..d1d16cfd 100644 --- a/py/tests/string.py +++ b/py/tests/string.py @@ -905,6 +905,15 @@ def index(s, i): a = "ABC" assert a.lower() == "abc" +doc="join" +assert ",".join(['a', 'b', 'c']) == "a,b,c" +assert " ".join(('a', 'b', 'c')) == "a b c" +assert " ".join("abc") == "a b c" +assert "".join(['a', 'b', 'c']) == "abc" +assert ",".join([]) == "" +assert ",".join(()) == "" +assertRaises(TypeError, lambda: ",".join([1, 2, 3])) + class Index: def __index__(self): return 1 From cff1e72e91fb927255adcdbb6d2fbc19a29ed052 Mon Sep 17 00:00:00 2001 From: AN Long Date: Sat, 28 Jun 2025 00:18:42 +0900 Subject: [PATCH 03/13] py: remove dead code --- py/int.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/py/int.go b/py/int.go index 919c28aa..511f1f88 100644 --- a/py/int.go +++ b/py/int.go @@ -411,9 +411,6 @@ func (a Int) M__truediv__(other Object) (Object, error) { return nil, err } fa := Float(a) - if err != nil { - return nil, err - } fb := b.(Float) if fb == 0 { return nil, divisionByZero @@ -427,9 +424,6 @@ func (a Int) M__rtruediv__(other Object) (Object, error) { return nil, err } fa := Float(a) - if err != nil { - return nil, err - } fb := b.(Float) if fa == 0 { return nil, divisionByZero From 53b7b8e01d998914a7f4179e3e051274f94b8990 Mon Sep 17 00:00:00 2001 From: AN Long Date: Sat, 28 Jun 2025 18:11:55 +0900 Subject: [PATCH 04/13] py: fix import on Windows --- py/import.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/py/import.go b/py/import.go index 709a4468..5ca6598a 100644 --- a/py/import.go +++ b/py/import.go @@ -7,7 +7,7 @@ package py import ( - "path" + "path/filepath" "strings" ) @@ -101,14 +101,14 @@ func ImportModuleLevelObject(ctx Context, name string, globals, locals StringDic // Convert import's dot separators into path seps parts := strings.Split(name, ".") - srcPathname := path.Join(parts...) + srcPathname := filepath.Join(parts...) opts := CompileOpts{ UseSysPaths: true, } if fromFile, ok := globals["__file__"]; ok { - opts.CurDir = path.Dir(string(fromFile.(String))) + opts.CurDir = filepath.Dir(string(fromFile.(String))) } module, err := RunFile(ctx, srcPathname, opts, name) From 43d2d6169f9e96cfba63434c4d5086e462a351e0 Mon Sep 17 00:00:00 2001 From: AN Long Date: Sat, 28 Jun 2025 18:52:37 +0900 Subject: [PATCH 05/13] py: fix exception repr --- py/exception.go | 11 +++++++++-- vm/tests/exceptions.py | 6 ++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/py/exception.go b/py/exception.go index 73f92747..3de2aa0d 100644 --- a/py/exception.go +++ b/py/exception.go @@ -368,9 +368,16 @@ func (e *Exception) M__str__() (Object, error) { } func (e *Exception) M__repr__() (Object, error) { - msg := e.Args.(Tuple)[0].(String) typ := e.Base.Name - return String(fmt.Sprintf("%s(%q)", typ, string(msg))), nil + args := e.Args.(Tuple) + if len(args) == 0 { + return String(fmt.Sprintf("%s()", typ)), nil + } + msg, err := args.M__repr__() + if err != nil { + return nil, err + } + return String(fmt.Sprintf("%s%s", typ, string(msg.(String)))), nil } // Check Interfaces diff --git a/vm/tests/exceptions.py b/vm/tests/exceptions.py index 665757bf..163dd644 100644 --- a/vm/tests/exceptions.py +++ b/vm/tests/exceptions.py @@ -165,4 +165,10 @@ ok = True assert ok, "ValueError not raised" +doc = "exception repr" +repr(ValueError()) == "ValueError()" +repr(ValueError(1)) == "ValueError(1)" +repr(ValueError(1, 2, 3)) == "ValueError(1, 2, 3)" +repr(ValueError("failed")) == 'ValueError("failed")' + doc = "finished" From 285aad11f2d09742709dbd5aca1b73a216bea090 Mon Sep 17 00:00:00 2001 From: AN Long Date: Mon, 30 Jun 2025 22:27:50 +0900 Subject: [PATCH 06/13] py: Implement str.count --- py/string.go | 41 +++++++++++++++++++++++++++++++++++++++++ py/tests/string.py | 9 +++++++++ 2 files changed, 50 insertions(+) diff --git a/py/string.go b/py/string.go index a987a11a..f88d6524 100644 --- a/py/string.go +++ b/py/string.go @@ -148,6 +148,13 @@ func init() { return Bool(false), nil }, 0, "endswith(suffix[, start[, end]]) -> bool") + StringType.Dict["count"] = MustNewMethod("count", func(self Object, args Tuple) (Object, error) { + return self.(String).Count(args) + }, 0, `count(sub[, start[, end]]) -> int +Return the number of non-overlapping occurrences of substring sub in +string S[start:end]. Optional arguments start and end are +interpreted as in slice notation.`) + StringType.Dict["find"] = MustNewMethod("find", func(self Object, args Tuple) (Object, error) { return self.(String).find(args) }, 0, `find(...) @@ -612,6 +619,40 @@ func (s String) M__contains__(item Object) (Object, error) { return NewBool(strings.Contains(string(s), string(needle))), nil } +func (s String) Count(args Tuple) (Object, error) { + var ( + pysub Object + pybeg Object = Int(0) + pyend Object = Int(s.len()) + pyfmt = "s|ii:count" + ) + err := ParseTuple(args, pyfmt, &pysub, &pybeg, &pyend) + if err != nil { + return nil, err + } + + var ( + beg = int(pybeg.(Int)) + end = int(pyend.(Int)) + size = s.len() + ) + if beg > size { + beg = size + } + if end < 0 { + end = size + } + if end > size { + end = size + } + + var ( + str = string(s.slice(beg, end, s.len())) + sub = string(pysub.(String)) + ) + return Int(strings.Count(str, sub)), nil +} + func (s String) find(args Tuple) (Object, error) { var ( pysub Object diff --git a/py/tests/string.py b/py/tests/string.py index d1d16cfd..d71e8645 100644 --- a/py/tests/string.py +++ b/py/tests/string.py @@ -944,5 +944,14 @@ def __index__(self): else: assert False, "TypeError not raised" +doc="count" +assert 'hello world'.count('l') == 3 +assert 'hello world'.count('l', 3) == 2 +assert 'hello world'.count('l', 3, 10) == 2 +assert 'hello world'.count('l', 3, 100) == 2 +assert 'hello world'.count('l', 3, 5) == 1 +assert 'hello world'.count('l', 3, 1) == 0 +assert 'hello world'.count('z') == 0 + doc="finished" From 8b4dffbc7c4082793da9a0ba6c4de0e59281c458 Mon Sep 17 00:00:00 2001 From: AN Long Date: Fri, 4 Jul 2025 03:50:12 +0900 Subject: [PATCH 07/13] all: handle SystemExit --- main.go | 42 ++++++++++++++++++++++++++++++++++++++---- repl/cli/cli.go | 8 ++++++-- repl/repl.go | 14 +++++++++----- stdlib/sys/sys.go | 6 +++++- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/main.go b/main.go index 8be7ea2e..8b55ab1e 100644 --- a/main.go +++ b/main.go @@ -60,6 +60,7 @@ func xmain(args []string) { defer pprof.StopCPUProfile() } + var err error // IF no args, enter REPL mode if len(args) == 0 { @@ -69,13 +70,46 @@ func xmain(args []string) { fmt.Printf("- go version: %s\n", runtime.Version()) replCtx := repl.New(ctx) - cli.RunREPL(replCtx) + err = cli.RunREPL(replCtx) + } else { + _, err = py.RunFile(ctx, args[0], py.CompileOpts{}, nil) + } + if err != nil { + if py.IsException(py.SystemExit, err) { + handleSystemExit(err.(py.ExceptionInfo).Value.(*py.Exception)) + } + py.TracebackDump(err) + os.Exit(1) + } +} +func handleSystemExit(exc *py.Exception) { + args := exc.Args.(py.Tuple) + if len(args) == 0 { + os.Exit(0) + } else if len(args) == 1 { + if code, ok := args[0].(py.Int); ok { + c, err := code.GoInt() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(c) + } + msg, err := py.ReprAsString(args[0]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + } else { + fmt.Fprintln(os.Stderr, msg) + } + os.Exit(1) } else { - _, err := py.RunFile(ctx, args[0], py.CompileOpts{}, nil) + msg, err := py.ReprAsString(args) if err != nil { - py.TracebackDump(err) - os.Exit(1) + fmt.Fprintln(os.Stderr, err) + } else { + fmt.Fprintln(os.Stderr, msg) } + os.Exit(1) } } diff --git a/repl/cli/cli.go b/repl/cli/cli.go index 6648094a..6f7e3966 100644 --- a/repl/cli/cli.go +++ b/repl/cli/cli.go @@ -117,7 +117,7 @@ func (rl *readline) Print(out string) { } // RunREPL starts the REPL loop -func RunREPL(replCtx *repl.REPL) { +func RunREPL(replCtx *repl.REPL) error { if replCtx == nil { replCtx = repl.New(nil) } @@ -144,6 +144,10 @@ func RunREPL(replCtx *repl.REPL) { if line != "" { rl.AppendHistory(line) } - rl.repl.Run(line) + err = rl.repl.Run(line) + if err != nil { + return err + } } + return nil } diff --git a/repl/repl.go b/repl/repl.go index f6639b25..3938a7b6 100644 --- a/repl/repl.go +++ b/repl/repl.go @@ -66,7 +66,7 @@ func (r *REPL) SetUI(term UI) { } // Run runs a single line of the REPL -func (r *REPL) Run(line string) { +func (r *REPL) Run(line string) error { // Override the PrintExpr output temporarily oldPrintExpr := vm.PrintExpr vm.PrintExpr = r.term.Print @@ -76,13 +76,13 @@ func (r *REPL) Run(line string) { if r.continuation { if line != "" { r.previous += string(line) + "\n" - return + return nil } } // need +"\n" because "single" expects \n terminated input toCompile := r.previous + string(line) if toCompile == "" { - return + return nil } code, err := py.Compile(toCompile+"\n", r.prog, py.SingleMode, 0, true) if err != nil { @@ -97,7 +97,7 @@ func (r *REPL) Run(line string) { r.previous += string(line) + "\n" r.term.SetPrompt(ContinuationPrompt) } - return + return nil } } r.continuation = false @@ -105,12 +105,16 @@ func (r *REPL) Run(line string) { r.previous = "" if err != nil { r.term.Print(fmt.Sprintf("Compile error: %v", err)) - return + return nil } _, err = r.Context.RunCode(code, r.Module.Globals, r.Module.Globals, nil) if err != nil { + if py.IsException(py.SystemExit, err) { + return err + } py.TracebackDump(err) } + return nil } // WordCompleter takes the currently edited line with the cursor diff --git a/stdlib/sys/sys.go b/stdlib/sys/sys.go index 3a2318eb..fc6efc5a 100644 --- a/stdlib/sys/sys.go +++ b/stdlib/sys/sys.go @@ -133,7 +133,11 @@ func sys_exit(self py.Object, args py.Tuple) (py.Object, error) { return nil, err } // Raise SystemExit so callers may catch it or clean up. - return py.ExceptionNew(py.SystemExit, args, nil) + exc, err := py.ExceptionNew(py.SystemExit, args, nil) + if err != nil { + return nil, err + } + return nil, exc.(*py.Exception) } const getdefaultencoding_doc = `getdefaultencoding() -> string From bbe47265af95f921cb863781f1db38d8c51b17f2 Mon Sep 17 00:00:00 2001 From: AN Long Date: Sat, 5 Jul 2025 00:04:12 +0900 Subject: [PATCH 08/13] pytest: ensure expected string's line sep is \n --- main_test.go | 1 + pytest/pytest.go | 1 + 2 files changed, 2 insertions(+) diff --git a/main_test.go b/main_test.go index e41ed869..f6347fda 100644 --- a/main_test.go +++ b/main_test.go @@ -48,6 +48,7 @@ func TestGPython(t *testing.T) { } want, err := os.ReadFile(fname) + want = bytes.ReplaceAll(want, []byte("\r\n"), []byte("\n")) if err != nil { t.Fatalf("could not read golden file: %+v", err) } diff --git a/pytest/pytest.go b/pytest/pytest.go index 7c331d26..d00f7fb0 100644 --- a/pytest/pytest.go +++ b/pytest/pytest.go @@ -273,6 +273,7 @@ func (task *Task) run() error { return fmt.Errorf("could not read golden output %q: %w", task.GoldFile, err) } + want = bytes.ReplaceAll(want, []byte("\r\n"), []byte("\n")) diff := cmp.Diff(string(want), string(got)) if !bytes.Equal(got, want) { out := fileBase + ".txt" From e20a7a44caad86725015a2a3c756197b05c8ae34 Mon Sep 17 00:00:00 2001 From: Christian Clauss Date: Mon, 13 Oct 2025 15:10:51 +0200 Subject: [PATCH 09/13] ci: update GitHub actions --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86a5f633..5fc4227a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} @@ -34,7 +34,7 @@ jobs: git config --global core.eol lf - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 1 From 759eb83ea8f2ddcfc846bc6b539cdbc4bc14e1df Mon Sep 17 00:00:00 2001 From: AN Long Date: Tue, 3 Feb 2026 00:56:59 +0900 Subject: [PATCH 10/13] py: implement builtin vars --- py/method.go | 1 + stdlib/builtin/builtin.go | 7 ++++++- stdlib/builtin/tests/builtin.py | 33 +++++++++++++++++++++++++++++++++ vm/eval.go | 17 +++++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/py/method.go b/py/method.go index c8b0ab03..438ad5f4 100644 --- a/py/method.go +++ b/py/method.go @@ -84,6 +84,7 @@ const ( InternalMethodImport InternalMethodEval InternalMethodExec + InternalMethodVars ) var MethodType = NewType("method", "method object") diff --git a/stdlib/builtin/builtin.go b/stdlib/builtin/builtin.go index 290cb939..96715321 100644 --- a/stdlib/builtin/builtin.go +++ b/stdlib/builtin/builtin.go @@ -63,7 +63,7 @@ func init() { py.MustNewMethod("setattr", builtin_setattr, 0, setattr_doc), py.MustNewMethod("sorted", builtin_sorted, 0, sorted_doc), py.MustNewMethod("sum", builtin_sum, 0, sum_doc), - // py.MustNewMethod("vars", builtin_vars, 0, vars_doc), + py.MustNewMethod("vars", py.InternalMethodVars, 0, vars_doc), } globals := py.StringDict{ "None": py.None, @@ -1189,6 +1189,11 @@ const globals_doc = `globals() -> dictionary Return the dictionary containing the current scope's global variables.` +const vars_doc = `vars([object]) -> dictionary + +Without an argument, equivalent to locals(). +With an argument, equivalent to object.__dict__.` + const sum_doc = `sum($module, iterable, start=0, /) -- Return the sum of a \'start\' value (default: 0) plus an iterable of numbers diff --git a/stdlib/builtin/tests/builtin.py b/stdlib/builtin/tests/builtin.py index ae4e8a5f..fc6bcdf8 100644 --- a/stdlib/builtin/tests/builtin.py +++ b/stdlib/builtin/tests/builtin.py @@ -107,6 +107,39 @@ def fn(x): assert locals()["x"] == 1 fn(1) +doc="vars" +def fn(x): + assert vars()["x"] == 1 +fn(1) + +# Test vars() with an object that has __dict__ (function objects have __dict__) +def test_func(): + pass + +assert vars(test_func) == test_func.__dict__ +assert isinstance(vars(test_func), dict) + +ok = False +try: + vars(test_func, test_func) +except TypeError: + ok = True +assert ok, "TypeError not raised for too many arguments" + +ok = False +try: + vars(x=1) +except TypeError: + ok = True +assert ok, "TypeError not raised for keyword arguments" + +ok = False +try: + vars(test_func, y=1) +except TypeError: + ok = True +assert ok, "TypeError not raised for keyword arguments with object" + def func(p): return p[1] diff --git a/vm/eval.go b/vm/eval.go index d32cf734..9db0fae9 100644 --- a/vm/eval.go +++ b/vm/eval.go @@ -1599,6 +1599,23 @@ func callInternal(fn py.Object, args py.Tuple, kwargs py.StringDict, f *py.Frame case py.InternalMethodExec: f.FastToLocals() return builtinExec(f.Context, args, kwargs, f.Locals, f.Globals, f.Builtins) + case py.InternalMethodVars: + if len(kwargs) > 0 { + return nil, py.ExceptionNewf(py.TypeError, "vars() takes no keyword arguments") + } + switch len(args) { + case 0: + f.FastToLocals() + return f.Locals, nil + case 1: + attr, err := py.GetAttrString(args[0], "__dict__") + if err != nil { + return nil, err + } + return attr, nil + default: + return nil, py.ExceptionNewf(py.TypeError, "vars() takes at most 1 argument (%d given)", len(args)) + } default: return nil, py.ExceptionNewf(py.SystemError, "Internal method %v not found", x) } From 530fdbdddbf3320321ca1b6630a061adbf79c66c Mon Sep 17 00:00:00 2001 From: AN Long Date: Wed, 25 Feb 2026 18:11:23 +0900 Subject: [PATCH 11/13] py,repl/cli,stdlib/builtin: implement input - py: implement input - add missing getline method on py.File - add parameters to readfile and add test --- py/file.go | 41 ++++++++++++++++ py/run.go | 5 ++ py/tests/file.py | 6 +++ repl/cli/cli.go | 8 ++++ stdlib/builtin/builtin.go | 83 ++++++++++++++++++++++++++++++++- stdlib/builtin/tests/builtin.py | 12 +++++ 6 files changed, 154 insertions(+), 1 deletion(-) diff --git a/py/file.go b/py/file.go index 3d9f0185..4335abdb 100644 --- a/py/file.go +++ b/py/file.go @@ -31,6 +31,9 @@ func init() { FileType.Dict["flush"] = MustNewMethod("flush", func(self Object) (Object, error) { return self.(*File).Flush() }, 0, "flush() -> Flush the write buffers of the stream if applicable. This does nothing for read-only and non-blocking streams.") + FileType.Dict["readline"] = MustNewMethod("readline", func(self Object, args Tuple, kwargs StringDict) (Object, error) { + return self.(*File).ReadLine(args, kwargs) + }, 0, "readline(size=-1, /) -> Read and return one line from the stream. If size is specified, at most size bytes will be read.\n\nThe line terminator is always b'\\n' for binary files; for text files, the newline argument to open can be used to select the line terminator(s) recognized.") } type FileMode int @@ -143,6 +146,44 @@ func (o *File) Read(args Tuple, kwargs StringDict) (Object, error) { return o.readResult(b) } +func (o *File) ReadLine(args Tuple, kwargs StringDict) (Object, error) { + var size Object = None + err := UnpackTuple(args, kwargs, "readline", 0, 1, &size) + if err != nil { + return nil, err + } + limit := int64(-1) + if size != None { + pyN, ok := size.(Int) + if !ok { + return nil, ExceptionNewf(TypeError, "integer argument expected, got '%s'", size.Type().Name) + } + limit, _ = pyN.GoInt64() + } + + var buf []byte + b := make([]byte, 1) + for { + if limit >= 0 && int64(len(buf)) >= limit { + break + } + n, err := o.File.Read(b) + if n > 0 { + buf = append(buf, b[0]) + if b[0] == '\n' { + break + } + } + if err != nil { + if err == io.EOF { + break + } + return nil, err + } + } + return o.readResult(buf) +} + func (o *File) Close() (Object, error) { _ = o.File.Close() return None, nil diff --git a/py/run.go b/py/run.go index 427cdbe6..cd584fc2 100644 --- a/py/run.go +++ b/py/run.go @@ -105,6 +105,11 @@ var ( // Compiles a python buffer into a py.Code object. // Returns a py.Code object or otherwise an error. Compile func(src, srcDesc string, mode CompileMode, flags int, dont_inherit bool) (*Code, error) + + // InputHook is an optional function that can be set to provide a custom input + // mechanism for the input() builtin. If nil, input() reads from sys.stdin. + // This is used by the REPL to integrate with the liner library. + InputHook func(prompt string) (string, error) ) // RunFile resolves the given pathname, compiles as needed, executes the code in the given module, and returns the Module to indicate success. diff --git a/py/tests/file.py b/py/tests/file.py index 898bc1bd..9c1d4e01 100644 --- a/py/tests/file.py +++ b/py/tests/file.py @@ -25,6 +25,12 @@ b = f.read() assert b == '' +doc = "readline" +f2 = open(__file__) +line = f2.readline() +assert line == '# Copyright 2018 The go-python Authors. All rights reserved.\n' +f2.close() + doc = "write" assertRaises(TypeError, f.write, 42) diff --git a/repl/cli/cli.go b/repl/cli/cli.go index 6f7e3966..f6f2f6c0 100644 --- a/repl/cli/cli.go +++ b/repl/cli/cli.go @@ -12,6 +12,7 @@ import ( "os/user" "path/filepath" + "github.com/go-python/gpython/py" "github.com/go-python/gpython/repl" "github.com/peterh/liner" ) @@ -124,6 +125,13 @@ func RunREPL(replCtx *repl.REPL) error { rl := newReadline(replCtx) replCtx.SetUI(rl) defer rl.Close() + + // Set up InputHook for the input() builtin function + py.InputHook = func(prompt string) (string, error) { + return rl.Prompt(prompt) + } + defer func() { py.InputHook = nil }() + err := rl.ReadHistory() if err != nil { if !os.IsNotExist(err) { diff --git a/stdlib/builtin/builtin.go b/stdlib/builtin/builtin.go index 96715321..870813ce 100644 --- a/stdlib/builtin/builtin.go +++ b/stdlib/builtin/builtin.go @@ -6,9 +6,12 @@ package builtin import ( + "errors" "fmt" + "io" "math/big" "strconv" + "strings" "unicode/utf8" "github.com/go-python/gpython/compile" @@ -44,7 +47,7 @@ func init() { // py.MustNewMethod("hash", builtin_hash, 0, hash_doc), py.MustNewMethod("hex", builtin_hex, 0, hex_doc), // py.MustNewMethod("id", builtin_id, 0, id_doc), - // py.MustNewMethod("input", builtin_input, 0, input_doc), + py.MustNewMethod("input", builtin_input, 0, input_doc), py.MustNewMethod("isinstance", builtin_isinstance, 0, isinstance_doc), // py.MustNewMethod("issubclass", builtin_issubclass, 0, issubclass_doc), py.MustNewMethod("iter", builtin_iter, 0, iter_doc), @@ -1181,6 +1184,84 @@ func builtin_chr(self py.Object, args py.Tuple) (py.Object, error) { return py.String(buf[:n]), nil } +const input_doc = `input([prompt]) -> string + +Read a string from standard input. The trailing newline is stripped. +The prompt string, if given, is printed to standard output without a +trailing newline before reading input. +If the user hits EOF (*nix: Ctrl-D, Windows: Ctrl-Z+Return), raise EOFError.` + +func builtin_input(self py.Object, args py.Tuple) (py.Object, error) { + var prompt py.Object = py.None + + err := py.UnpackTuple(args, nil, "input", 0, 1, &prompt) + if err != nil { + return nil, err + } + + // Use InputHook if available (e.g., in REPL mode) + if py.InputHook != nil { + promptStr := "" + if prompt != py.None { + s, ok := prompt.(py.String) + if !ok { + return nil, py.ExceptionNewf(py.TypeError, "input() prompt must be a string") + } + promptStr = string(s) + } + line, err := py.InputHook(promptStr) + if err != nil { + if errors.Is(err, io.EOF) { + return nil, py.ExceptionNewf(py.EOFError, "EOF when reading a line") + } + return nil, err + } + return py.String(line), nil + } + + sysModule, err := self.(*py.Module).Context.GetModule("sys") + if err != nil { + return nil, err + } + + stdin := sysModule.Globals["stdin"] + stdout := sysModule.Globals["stdout"] + + if prompt != py.None { + write, err := py.GetAttrString(stdout, "write") + if err != nil { + return nil, err + } + _, err = py.Call(write, py.Tuple{prompt}, nil) + if err != nil { + return nil, err + } + + flush, err := py.GetAttrString(stdout, "flush") + if err == nil { + py.Call(flush, nil, nil) + } + } + + readline, err := py.GetAttrString(stdin, "readline") + if err != nil { + return nil, err + } + result, err := py.Call(readline, nil, nil) + if err != nil { + return nil, err + } + line, ok := result.(py.String) + if !ok { + return nil, py.ExceptionNewf(py.TypeError, "object.readline() should return a str object, got %s", result.Type().Name) + } + if line == "" { + return nil, py.ExceptionNewf(py.EOFError, "EOF when reading a line") + } + line = py.String(strings.TrimRight(string(line), "\r\n")) + return line, nil +} + const locals_doc = `locals() -> dictionary Update and return a dictionary containing the current scope's local variables.` diff --git a/stdlib/builtin/tests/builtin.py b/stdlib/builtin/tests/builtin.py index fc6bcdf8..77a831a2 100644 --- a/stdlib/builtin/tests/builtin.py +++ b/stdlib/builtin/tests/builtin.py @@ -502,4 +502,16 @@ class C: pass assert lib.libvar == 43 assert lib.libclass().method() == 44 +doc="input" +import sys +class MockStdin: + def __init__(self, line): + self._line = line + def readline(self): + return self._line +old_stdin = sys.stdin +sys.stdin = MockStdin("hello\n") +assert input() == "hello" +sys.stdin = old_stdin + doc="finished" From c324a9a85dc022b9a765125767c338fd061b6f39 Mon Sep 17 00:00:00 2001 From: AN Long Date: Fri, 27 Feb 2026 18:34:12 +0900 Subject: [PATCH 12/13] py: harden __import__ argument handling Fixes #204. Co-authored-by: Sebastien Binet --- py/import.go | 38 +++++++++++++++++++++++++++++---- stdlib/builtin/tests/builtin.py | 20 +++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/py/import.go b/py/import.go index 5ca6598a..8adc8568 100644 --- a/py/import.go +++ b/py/import.go @@ -108,7 +108,9 @@ func ImportModuleLevelObject(ctx Context, name string, globals, locals StringDic } if fromFile, ok := globals["__file__"]; ok { - opts.CurDir = filepath.Dir(string(fromFile.(String))) + if fromFileStr, ok := fromFile.(String); ok { + opts.CurDir = filepath.Dir(string(fromFileStr)) + } } module, err := RunFile(ctx, srcPathname, opts, name) @@ -344,14 +346,42 @@ func BuiltinImport(ctx Context, self Object, args Tuple, kwargs StringDict, curr var globals Object = currentGlobal var locals Object = NewStringDict() var fromlist Object = Tuple{} + var fromlistTuple Tuple var level Object = Int(0) err := ParseTupleAndKeywords(args, kwargs, "U|OOOi:__import__", kwlist, &name, &globals, &locals, &fromlist, &level) if err != nil { return nil, err } - if fromlist == None { - fromlist = Tuple{} + levelObj, ok := level.(Int) + if !ok { + return nil, ExceptionNewf(TypeError, "__import__() argument 5 must be int, not %s", level.Type().Name) + } + levelInt, err := levelObj.GoInt() + if err != nil { + return nil, err + } + + globalsDict, ok := globals.(StringDict) + if !ok { + if levelInt > 0 { + return nil, ExceptionNewf(TypeError, "globals must be a dict") + } + globalsDict = StringDict{} } - return ImportModuleLevelObject(ctx, string(name.(String)), globals.(StringDict), locals.(StringDict), fromlist.(Tuple), int(level.(Int))) + + localsDict, ok := locals.(StringDict) + if !ok { + localsDict = StringDict{} + } + + fromlistTuple = Tuple{} + if fromlist != None { + fromlistTuple, err = SequenceTuple(fromlist) + if err != nil { + return nil, err + } + } + + return ImportModuleLevelObject(ctx, string(name.(String)), globalsDict, localsDict, fromlistTuple, levelInt) } diff --git a/stdlib/builtin/tests/builtin.py b/stdlib/builtin/tests/builtin.py index 77a831a2..36cb8ff2 100644 --- a/stdlib/builtin/tests/builtin.py +++ b/stdlib/builtin/tests/builtin.py @@ -501,6 +501,26 @@ class C: pass assert lib.libfn() == 42 assert lib.libvar == 43 assert lib.libclass().method() == 44 +lib = __import__("lib", {}, {}, [""]) +assert lib.libfn() == 42 +ok = False +try: + __import__("lib", {}, {}, 1) +except TypeError: + ok = True +assert ok, "TypeError not raised" +lib = __import__("lib", 1, {}, [""]) +assert lib.libfn() == 42 +ok = False +try: + __import__("lib", 1, {}, [""], 1) +except TypeError as e: + if e.args[0] != "globals must be a dict": + raise + ok = True +assert ok, "TypeError not raised" +lib = __import__("lib", {"__file__": 1}, {}, [""]) +assert lib.libfn() == 42 doc="input" import sys From 444ae5ed29cfbc767b6592a3f27d5d05ca24b609 Mon Sep 17 00:00:00 2001 From: AN Long Date: Mon, 2 Mar 2026 22:02:41 +0900 Subject: [PATCH 13/13] stdlib/builtin: add quit and exit In CPython's REPL, there are other ways to `quit` or `exit` the REPL without calling a function (by simply typing `quit` or `exit`). These names are injected via `site.py`. Since GPython does not have a site.py, this REPL usage is not implemented. Fixes #140. --- stdlib/builtin/builtin.go | 31 +++++++++++++++++++++++++++++++ stdlib/builtin/tests/builtin.py | 9 +++++++++ 2 files changed, 40 insertions(+) diff --git a/stdlib/builtin/builtin.go b/stdlib/builtin/builtin.go index 870813ce..dae29324 100644 --- a/stdlib/builtin/builtin.go +++ b/stdlib/builtin/builtin.go @@ -40,6 +40,7 @@ func init() { py.MustNewMethod("divmod", builtin_divmod, 0, divmod_doc), py.MustNewMethod("eval", py.InternalMethodEval, 0, eval_doc), py.MustNewMethod("exec", py.InternalMethodExec, 0, exec_doc), + py.MustNewMethod("exit", builtin_exit, 0, exit_doc), // py.MustNewMethod("format", builtin_format, 0, format_doc), py.MustNewMethod("getattr", builtin_getattr, 0, getattr_doc), py.MustNewMethod("globals", py.InternalMethodGlobals, 0, globals_doc), @@ -61,6 +62,7 @@ func init() { py.MustNewMethod("ord", builtin_ord, 0, ord_doc), py.MustNewMethod("pow", builtin_pow, 0, pow_doc), py.MustNewMethod("print", builtin_print, 0, print_doc), + py.MustNewMethod("quit", builtin_quit, 0, quit_doc), py.MustNewMethod("repr", builtin_repr, 0, repr_doc), py.MustNewMethod("round", builtin_round, 0, round_doc), py.MustNewMethod("setattr", builtin_setattr, 0, setattr_doc), @@ -1262,6 +1264,35 @@ func builtin_input(self py.Object, args py.Tuple) (py.Object, error) { return line, nil } +const exit_doc = `exit([status]) + +Exit the interpreter by raising SystemExit(status).` + +const quit_doc = `quit([status]) + +Alias for exit().` + +func builtin_exit(self py.Object, args py.Tuple) (py.Object, error) { + return builtinExit("exit", args) +} + +func builtin_quit(self py.Object, args py.Tuple) (py.Object, error) { + return builtinExit("quit", args) +} + +func builtinExit(name string, args py.Tuple) (py.Object, error) { + var exitCode py.Object + err := py.UnpackTuple(args, nil, name, 0, 1, &exitCode) + if err != nil { + return nil, err + } + exc, err := py.ExceptionNew(py.SystemExit, args, nil) + if err != nil { + return nil, err + } + return nil, exc.(*py.Exception) +} + const locals_doc = `locals() -> dictionary Update and return a dictionary containing the current scope's local variables.` diff --git a/stdlib/builtin/tests/builtin.py b/stdlib/builtin/tests/builtin.py index 36cb8ff2..0fef8d23 100644 --- a/stdlib/builtin/tests/builtin.py +++ b/stdlib/builtin/tests/builtin.py @@ -79,6 +79,15 @@ assert exec("b = a+100", glob) == None assert glob["b"] == 200 +doc="exit/quit" +assertRaises(SystemExit, exit) +assertRaises(SystemExit, exit, 0) +assertRaises(SystemExit, exit, 3) +assertRaises(SystemExit, quit) +assertRaises(SystemExit, quit, "bye") +assertRaises(TypeError, exit, 1, 2) +assertRaises(TypeError, quit, 1, 2) + doc="getattr" class C: def __init__(self):