From 2d33356a40c99c3c9dd30a4123588043181d2162 Mon Sep 17 00:00:00 2001 From: kortschak Date: Sun, 2 Jul 2017 20:23:20 +0930 Subject: [PATCH] Improve test coverage and handle version 2 of OpenCPU --- .travis.yml | 4 + README.md | 2 +- arrgh.go | 29 ++++-- arrgh_example2_test.go | 2 +- arrgh_test.go | 229 +++++++++++++++++++++++++++++++++++++++++ install-opencpu-2.0 | 5 + 6 files changed, 259 insertions(+), 12 deletions(-) create mode 100644 arrgh_test.go create mode 100644 install-opencpu-2.0 diff --git a/.travis.yml b/.travis.yml index 55571e0..363d9c1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,8 +15,12 @@ install: - sudo apt-get update - sudo apt-get upgrade -y - sudo apt-get install -y opencpu-server + - curl https://cran.r-project.org/src/contrib/semver_0.2.0.tar.gz -o semver_0.2.0.tar.gz + - sudo R CMD INSTALL semver_0.2.0.tar.gz script: + - go test -v + - . ./install-opencpu-2.0 - go test -v -covermode=count -coverprofile=profile.cov after_success: diff --git a/README.md b/README.md index ba463c0..2e155a5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ arrgh (Pronunciation: /ɑː/ or /är/) is an interface to the [OpenCPU](https:// ## Installation -arrgh requires a [Go](http://golang.org) installation, and if using a local R instance [OpenCPU](https://www.opencpu.org/download.html) must be installed as an R package. +arrgh requires a [Go](http://golang.org) installation, and if using a local R instance [OpenCPU](https://www.opencpu.org/download.html) (tested on v1.6 and v2.0) and [semver](https://cran.r-project.org/web/packages/semver/index.html) must be installed as R packages. ## Documentation diff --git a/arrgh.go b/arrgh.go index 3b4bccd..0ed2cab 100644 --- a/arrgh.go +++ b/arrgh.go @@ -27,10 +27,9 @@ import ( // Session holds OpenCPU session connection information. type Session struct { - cmd *exec.Cmd - host *url.URL - control io.Writer - root string + cmd *exec.Cmd + host *url.URL + root string } // NewLocalSession starts an R instance using the executable in the given @@ -64,7 +63,7 @@ func NewLocalSession(path, root string, port int, timeout time.Duration) (*Sessi } sess.host.Path = pth.Join(sess.host.Path, root) sess.root = pth.Join("/", root) - sess.control, err = sess.cmd.StdinPipe() + control, err := sess.cmd.StdinPipe() if err != nil { panic(err) } @@ -72,7 +71,19 @@ func NewLocalSession(path, root string, port int, timeout time.Duration) (*Sessi if err != nil { return nil, err } - fmt.Fprintf(sess.control, "library(opencpu); opencpu$start(%d)\n", port) + + // If people ask why this package has the name it does, just point to this; + // a version return function in base returns a type that is not accepted by + // a package whose sole purpose is to parse version values. + const startServer = `library(opencpu) +library(semver) +if (parse_version(as.character(packageVersion("opencpu"))) < "2.0.0") { + opencpu$start(%[1]d) +} else { + ocpu_start_server(port=%[1]d) +} +` + fmt.Fprintf(control, startServer, port) runtime.SetFinalizer(&sess, func(s *Session) { s.Close() }) @@ -84,7 +95,7 @@ func NewLocalSession(path, root string, port int, timeout time.Duration) (*Sessi if err == nil { return &sess, nil } else if timeout > 0 && time.Now().Sub(start) > timeout { - sess.control.Write([]byte("opencpu$stop()\n")) + sess.cmd.Process.Kill() return nil, err } } @@ -100,9 +111,7 @@ func (s *Session) Close() error { return nil } s.host = nil - s.control.Write([]byte("opencpu$stop()\nq()\n")) - s.control = nil - return s.cmd.Wait() + return s.cmd.Process.Kill() } // NewRemoteSession connects to the OpenCPU server at the specified host. The diff --git a/arrgh_example2_test.go b/arrgh_example2_test.go index e3ad3d6..a8c0f1e 100644 --- a/arrgh_example2_test.go +++ b/arrgh_example2_test.go @@ -37,7 +37,7 @@ func Example_linear() { "library/base/R/identity", "application/x-www-form-urlencoded", nil, - strings.NewReader(`x=coef(lm(speed ~ dist, data = cars))`), + strings.NewReader("x="+url.QueryEscape("coef(lm(speed ~ dist, data = cars))")), ) if err != nil { log.Fatal(err) diff --git a/arrgh_test.go b/arrgh_test.go new file mode 100644 index 0000000..e32ddfa --- /dev/null +++ b/arrgh_test.go @@ -0,0 +1,229 @@ +// Copyright ©2017 Dan Kortschak. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package arrgh + +import ( + "bufio" + "bytes" + "io" + "io/ioutil" + "log" + "mime" + "mime/multipart" + "net/url" + "os" + "path" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +var sessionTests = []struct { + path string + content string + params url.Values + query func() io.Reader + + want string +}{ + { + path: "library/base/R/identity", + content: "application/x-www-form-urlencoded", + params: nil, + query: func() io.Reader { + return strings.NewReader("x=" + url.QueryEscape("coef(lm(speed ~ dist, data = cars))")) + }, + + want: "[8.2839056418, 0.16556757464]\n", + }, +} + +func TestLocalSession(t *testing.T) { + r, err := NewLocalSession("", "", 3000, 10*time.Second) + if err != nil { + t.Fatalf("failed to start local opencpu session: %v", err) + } + defer r.Close() + +tests: + for _, test := range sessionTests { + resp, err := r.Post(test.path, test.content, test.params, test.query()) + if err != nil { + t.Errorf("unexpected error for POST: %v", err) + continue + } + defer resp.Body.Close() + + sc := bufio.NewScanner(resp.Body) + var val string + for sc.Scan() { + p, err := filepath.Rel(r.Root(), sc.Text()) + if err != nil { + t.Errorf("failed to get relative filepath: %v", err) + continue tests + } + if path.Base(p) == ".val" { + val = p + break + } + } + + res, err := r.Get(path.Join(val, "json"), url.Values{"digits": []string{"10"}}) + if err != nil { + log.Fatal(err) + } + defer res.Body.Close() + + got, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Errorf("unexpected error reading result: %v", err) + continue + } + + if string(got) != test.want { + t.Errorf("unexpected result: got:%q want:%q", got, test.want) + } + } +} + +func TestRemoteSession(t *testing.T) { + r, err := NewRemoteSession("http://public.opencpu.org", "", 10*time.Second) + if err != nil { + t.Fatalf("failed to start local opencpu session: %v", err) + } + defer r.Close() + +tests: + for _, test := range sessionTests { + resp, err := r.Post(test.path, test.content, test.params, test.query()) + if err != nil { + t.Errorf("unexpected error for POST: %v", err) + continue + } + defer resp.Body.Close() + + sc := bufio.NewScanner(resp.Body) + var val string + for sc.Scan() { + p, err := filepath.Rel(r.Root(), sc.Text()) + if err != nil { + t.Errorf("failed to get relative filepath: %v", err) + continue tests + } + if path.Base(p) == ".val" { + val = p + break + } + } + + res, err := r.Get(path.Join(val, "json"), url.Values{"digits": []string{"10"}}) + if err != nil { + log.Fatal(err) + } + defer res.Body.Close() + + got, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Errorf("unexpected error reading result: %v", err) + continue + } + + if string(got) != test.want { + t.Errorf("unexpected result: got:%q want:%q", got, test.want) + } + } +} + +var multipartTests = []struct { + params Params + files Files +}{ + { + params: Params{"header": "bar", "baz": "qux"}, + files: Files{"boop": namedReader{name: "boop", ReadSeeker: strings.NewReader("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")}}, + }, + { + params: Params{"header": "FALSE"}, + files: Files{"mydata.csv": func() *os.File { + f, err := os.Open("mydata.csv") + if err != nil { + panic("failed to to open test file") + } + return f + }(), + }, + }, +} + +func TestMultipart(t *testing.T) { + for _, test := range multipartTests { + content, body, err := Multipart(test.params, test.files) + if err != nil { + t.Errorf("unexpected error: %v", err) + continue + } + if !strings.HasPrefix(content, "multipart/form-data; boundary=") { + t.Errorf("unexpected content string: got:%q", content) + } + + typ, params, err := mime.ParseMediaType(content) + if err != nil { + t.Errorf("failed to parse MIME type: %v", err) + continue + } + if !strings.HasPrefix(typ, "multipart/") { + t.Error("expected multipart MIME") + continue + } + mr := multipart.NewReader(body, params["boundary"]) + + gotParams := make(Params) + parts: + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Errorf("failed to parse MIME part: %v", err) + continue parts + } + b, err := ioutil.ReadAll(p) + if err != nil { + t.Errorf("failed to read MIME part: %v", err) + continue parts + } + if name := p.FileName(); name != "" { + rs := test.files[name].(io.ReadSeeker) + _, err := rs.Seek(0, os.SEEK_SET) + if err != nil { + t.Fatalf("failed to seek to start: %v", err) + } + want, err := ioutil.ReadAll(rs) + if err != nil { + t.Fatalf("failed to read want text: %v", err) + } + if !bytes.Equal(b, want) { + t.Errorf("unexpected file content: got:%q want:%q", b, want) + } + } else if name := p.FormName(); name != "" { + gotParams[name] = string(b) + } + } + + if !reflect.DeepEqual(gotParams, test.params) { + t.Errorf("unexpected parameters: got:%v want:%v", gotParams, test.params) + } + } +} + +type namedReader struct { + name string + io.ReadSeeker +} + +func (r namedReader) Name() string { return r.name } diff --git a/install-opencpu-2.0 b/install-opencpu-2.0 new file mode 100644 index 0000000..270b3e8 --- /dev/null +++ b/install-opencpu-2.0 @@ -0,0 +1,5 @@ +sudo R --vanilla <