Skip to content

Commit 0ce557b

Browse files
authored
Feat/support cpython pandas (#7)
* feat: can import pandas * feat: support pandas in cpython wasm * fix: test timeout
1 parent 1738c06 commit 0ce557b

10 files changed

Lines changed: 700 additions & 45 deletions

File tree

adapter/cpython/cpython.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,14 @@ func WithStdlib() (sango.Option, error) {
4141
if err != nil {
4242
return nil, fmt.Errorf("cpython: open embedded stdlib zip: %w", err)
4343
}
44+
45+
fsys, err := newMemFSFromZip(zr)
46+
if err != nil {
47+
return nil, fmt.Errorf("cpython: decompress embedded stdlib: %w", err)
48+
}
49+
4450
return sango.WithModuleConfigModifier(func(c wazero.ModuleConfig) wazero.ModuleConfig {
45-
return c.WithFSConfig(wazero.NewFSConfig().WithFSMount(zr, stdlibGuestPath))
51+
return c.WithFSConfig(wazero.NewFSConfig().WithFSMount(fsys, stdlibGuestPath))
4652
}), nil
4753
}
4854

adapter/cpython/cpython.wasm

15.3 MB
Binary file not shown.

adapter/cpython/cpython_test.go

Lines changed: 158 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"errors"
7+
"sync"
78
"testing"
89

910
"github.com/google/go-cmp/cmp"
@@ -263,18 +264,7 @@ func TestCPython_Eval(t *testing.T) {
263264
}
264265

265266
func TestRealWasm_OneshotAcquire(t *testing.T) {
266-
if testing.Short() {
267-
t.Skip("skip real wasm test in -short mode")
268-
}
269-
stdlib, err := cpython.WithStdlib()
270-
if err != nil {
271-
t.Fatal(err)
272-
}
273-
rt, err := sango.New(t.Context(), cpython.Wasm(), cpython.CPython(), sango.WithWASI(), stdlib)
274-
if err != nil {
275-
t.Fatal(err)
276-
}
277-
defer rt.Close(t.Context())
267+
rt := newExtRuntime(t)
278268

279269
inst, err := rt.Acquire(t.Context())
280270
if err != nil {
@@ -295,19 +285,7 @@ func TestRealWasm_OneshotAcquire(t *testing.T) {
295285
}
296286

297287
func TestRealWasm_Fork(t *testing.T) {
298-
if testing.Short() {
299-
t.Skip("skip real wasm test in -short mode")
300-
}
301-
302-
stdlib, err := cpython.WithStdlib()
303-
if err != nil {
304-
t.Fatal(err)
305-
}
306-
rt, err := sango.New(t.Context(), cpython.Wasm(), cpython.CPython(), sango.WithWASI(), stdlib)
307-
if err != nil {
308-
t.Fatal(err)
309-
}
310-
defer rt.Close(t.Context())
288+
rt := newExtRuntime(t)
311289

312290
ctx := t.Context()
313291

@@ -439,15 +417,21 @@ func evalOK(t *testing.T, inst *sango.Instance, code string) string {
439417
return string(res.Value)
440418
}
441419

420+
var sharedExtRuntime = sync.OnceValues(func() (*sango.Runtime, error) {
421+
opt, err := cpython.WithStdlib()
422+
if err != nil {
423+
return nil, err
424+
}
425+
return sango.New(context.Background(), cpython.Wasm(), cpython.CPython(),
426+
sango.WithWASI(), opt)
427+
})
428+
442429
func newExtRuntime(t *testing.T) *sango.Runtime {
443430
t.Helper()
444-
stdlib, _ := cpython.WithStdlib()
445-
rt, err := sango.New(t.Context(), cpython.Wasm(), cpython.CPython(),
446-
sango.WithWASI(), stdlib)
431+
rt, err := sharedExtRuntime()
447432
if err != nil {
448433
t.Fatal(err)
449434
}
450-
t.Cleanup(func() { rt.Close(t.Context()) })
451435
return rt
452436
}
453437

@@ -511,11 +495,153 @@ func TestNumpy_Works(t *testing.T) {
511495
t.Log(evalOK(t, fork, `str(a.sum())`))
512496
}
513497

514-
func TestNumpy_FFT(t *testing.T) {
515-
if testing.Short() {
516-
t.Skip("skip real wasm test in -short mode")
498+
func TestPandas_Works(t *testing.T) {
499+
rt := newExtRuntime(t)
500+
inst, err := rt.Acquire(t.Context())
501+
if err != nil {
502+
t.Fatal(err)
517503
}
504+
defer inst.Release()
505+
506+
t.Log(evalOK(t, inst, `
507+
import traceback
508+
try:
509+
pd.Timestamp("2024-01-01").tz_localize("Asia/Tokyo")
510+
_r = "ok"
511+
except Exception:
512+
_r = traceback.format_exc()
513+
_r
514+
`))
515+
516+
t.Log(evalOK(t, inst, `
517+
import os
518+
_r = ""
519+
for p in ("/lib/python3.13/pytz/zoneinfo/Asia/Tokyo",
520+
"/lib/python3.13/tzdata/zoneinfo/Asia/Tokyo"):
521+
_r += p + " exists=" + str(os.path.exists(p))
522+
try:
523+
f = open(p, "rb")
524+
_r += " seekable=" + str(f.seekable())
525+
f.close()
526+
except Exception as e:
527+
_r += " err=" + repr(e)
528+
_r += "\n"
529+
_r
530+
`))
531+
532+
t.Log(evalOK(t, inst, `import pandas as pd; pd.__version__`))
533+
534+
t.Run("low visibility ext modules", func(t *testing.T) {
535+
evalOK(t, inst, `import pandas._libs.pandas_datetime`)
536+
evalOK(t, inst, `import pandas._libs.pandas_parser`)
537+
evalOK(t, inst, `import pandas._libs.window.aggregations`)
538+
evalOK(t, inst, `import pandas._libs.tslibs.np_datetime`)
539+
})
540+
541+
t.Run("dataframe basics", func(t *testing.T) {
542+
got := evalOK(t, inst, `str(pd.DataFrame({"a": [1, 2, 3]})["a"].sum())`)
543+
if got != "'6'" {
544+
t.Fatalf("got %s, want '6'", got)
545+
}
546+
})
547+
548+
t.Run("groupby", func(t *testing.T) {
549+
got := evalOK(t, inst, `str(pd.DataFrame(`+
550+
`{"k": ["x", "x", "y"], "v": [1, 2, 3]}`+
551+
`).groupby("k")["v"].sum()["x"])`)
552+
if got != "'3'" {
553+
t.Fatalf("got %s, want '3'", got)
554+
}
555+
})
556+
557+
t.Run("read_csv", func(t *testing.T) {
558+
got := evalOK(t, inst,
559+
`import io; str(pd.read_csv(io.StringIO("a,b\n1,2\n3,4"))["b"].sum())`)
560+
if got != "'6'" {
561+
t.Fatalf("got %s, want '6'", got)
562+
}
563+
})
518564

565+
t.Run("rolling window", func(t *testing.T) {
566+
got := evalOK(t, inst, `str(pd.Series([1, 2, 3]).rolling(2).sum()[2])`)
567+
if got != "'5.0'" {
568+
t.Fatalf("got %s, want '5.0'", got)
569+
}
570+
})
571+
572+
t.Run("to_json", func(t *testing.T) {
573+
got := evalOK(t, inst, `pd.DataFrame({"a": [1]}).to_json()`)
574+
if got != `'{"a":{"0":1}}'` {
575+
t.Fatalf("got %s", got)
576+
}
577+
})
578+
579+
t.Run("datetime", func(t *testing.T) {
580+
got := evalOK(t, inst, `str(pd.to_datetime("2024-03-15").day)`)
581+
if got != "'15'" {
582+
t.Fatalf("got %s, want '15'", got)
583+
}
584+
})
585+
586+
t.Run("timezone", func(t *testing.T) {
587+
got := evalOK(t, inst,
588+
`str(pd.Timestamp("2024-01-01").tz_localize("Asia/Tokyo").tz)`)
589+
if got != "'Asia/Tokyo'" {
590+
t.Fatalf("got %s, want 'Asia/Tokyo' (tzdata is probably missing)", got)
591+
}
592+
})
593+
594+
t.Run("guest error is a value, not a crash", func(t *testing.T) {
595+
res, err := inst.Eval(t.Context(), []byte(`pd.DataFrame({"a": [1]})["missing"]`))
596+
if err != nil {
597+
t.Fatalf("infra error (instance died?): %v", err)
598+
}
599+
if res.OK() {
600+
t.Fatalf("expected KeyError, got %q", res.Value)
601+
}
602+
if len(res.Err.Message) == 0 {
603+
t.Fatal("empty error message")
604+
}
605+
t.Logf("KeyError (expected): %s", res.Err)
606+
})
607+
608+
t.Run("instance survives a thrown exception", func(t *testing.T) {
609+
got := evalOK(t, inst, `str(pd.DataFrame({"a": [1, 2]})["a"].sum())`)
610+
if got != "'3'" {
611+
t.Fatalf("instance broken after exception: got %s", got)
612+
}
613+
})
614+
615+
t.Run("snapshot and restore", func(t *testing.T) {
616+
ctx := t.Context()
617+
618+
if _, err := inst.Eval(ctx, []byte(
619+
`df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})`)); err != nil {
620+
t.Fatal(err)
621+
}
622+
623+
snap, err := rt.Snapshot(inst)
624+
if err != nil {
625+
t.Fatal(err)
626+
}
627+
fork, err := rt.Restore(ctx, snap)
628+
if err != nil {
629+
t.Fatal(err)
630+
}
631+
defer fork.Release()
632+
633+
got := evalOK(t, fork, `str(df["b"].sum())`)
634+
if got != "'15'" {
635+
t.Fatalf("got %s, want '15'", got)
636+
}
637+
638+
if got := evalOK(t, fork, `str("pandas" in __import__("sys").modules)`); got != "'True'" {
639+
t.Fatalf("pandas not in restored sys.modules: %s", got)
640+
}
641+
})
642+
}
643+
644+
func TestNumpy_FFT(t *testing.T) {
519645
rt := newExtRuntime(t)
520646
inst, err := rt.Acquire(t.Context())
521647
if err != nil {

0 commit comments

Comments
 (0)