diff --git a/hrp/cmd/build.go b/hrp/cmd/build.go new file mode 100644 index 00000000..093132ea --- /dev/null +++ b/hrp/cmd/build.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/httprunner/httprunner/v4/hrp/internal/build" +) + +var buildCmd = &cobra.Command{ + Use: "build $path ...", + Short: "build plugin for testing", + Long: `build python/go plugin for testing`, + Example: ` $ hrp build plugin/debugtalk.go + $ hrp build plugin/debugtalk.py`, + Args: cobra.MinimumNArgs(1), + PreRun: func(cmd *cobra.Command, args []string) { + setLogLevel(logLevel) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return build.Run(args) + }, +} + +func init() { + rootCmd.AddCommand(buildCmd) +} diff --git a/hrp/internal/build/examples/debugtalk_no_fungo.go b/hrp/internal/build/examples/debugtalk_no_fungo.go new file mode 100644 index 00000000..9a57a294 --- /dev/null +++ b/hrp/internal/build/examples/debugtalk_no_fungo.go @@ -0,0 +1,40 @@ +package examples + +import ( + "fmt" +) + +func SumTwoInt(a, b int) int { + return a + b +} + +func SumInts(args ...int) int { + var sum int + for _, arg := range args { + sum += arg + } + return sum +} + +func Sum(args ...interface{}) (interface{}, error) { + var sum float64 + for _, arg := range args { + switch v := arg.(type) { + case int: + sum += float64(v) + case float64: + sum += v + default: + return nil, fmt.Errorf("unexpected type: %T", arg) + } + } + return sum, nil +} + +func SetupHookExample(args string) string { + return fmt.Sprintf("step name: %v, setup...", args) +} + +func TeardownHookExample(args string) string { + return fmt.Sprintf("step name: %v, teardown...", args) +} diff --git a/hrp/internal/build/examples/debugtalk_no_funppy.py b/hrp/internal/build/examples/debugtalk_no_funppy.py new file mode 100644 index 00000000..370206d6 --- /dev/null +++ b/hrp/internal/build/examples/debugtalk_no_funppy.py @@ -0,0 +1,53 @@ +import logging +import time +from typing import List + + +def sleep(n_secs): + time.sleep(n_secs) + + +def sum(*args): + result = 0 + for arg in args: + result += arg + return result + + +def sum_ints(*args: List[int]) -> int: + result = 0 + for arg in args: + result += arg + return result + + +def sum_two_int(a: int, b: int) -> int: + return a + b + + +def sum_two_string(a: str, b: str) -> str: + return a + b + + +def sum_strings(*args: List[str]) -> str: + result = "" + for arg in args: + result += arg + return result + + +def concatenate(*args: List[str]) -> str: + result = "" + for arg in args: + result += str(arg) + return result + + +def setup_hook_example(name): + logging.warning("setup_hook_example") + return f"setup_hook_example: {name}" + + +def teardown_hook_example(name): + logging.warning("teardown_hook_example") + return f"teardown_hook_example: {name}" diff --git a/hrp/internal/build/main.go b/hrp/internal/build/main.go new file mode 100644 index 00000000..747ed181 --- /dev/null +++ b/hrp/internal/build/main.go @@ -0,0 +1,304 @@ +package build + +import ( + "bufio" + _ "embed" + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "text/template" + + "github.com/httprunner/funplugin/shared" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +const ( + funppy = `import funppy` + fungo = `"github.com/httprunner/funplugin/fungo"` + regexPythonFunctionName = `def ([a-zA-Z_]\w*)\(.*\)` + regexGoImport = `import\s*\(\n([\s\S]*)\n\)` + regexGoFunctionName = `func ([a-zA-Z_]\w*)\(.*\)` + regexGoFunctionContent = `func [\s\S]*?\n}\n` +) + +//go:embed templates/debugtalkPythonTemplate +var pyTemplate string + +//go:embed templates/debugtalkGoTemplate +var goTemplate string + +type TemplateContent struct { + Fun string // funplugin package + Regexps *Regexps // match import/function + Imports []string // python/go import + FromImports []string // python from...import... + Functions []string // python/go function + FunctionNames []string // function name set by user + FunctionSnakeNames []string // function snake name converts by function name for registering plugin +} + +type Regexps struct { + Import *regexp.Regexp + FunctionName *regexp.Regexp + FunctionContent *regexp.Regexp // including function define and body +} + +func (t *TemplateContent) parseGoContent(path string) error { + log.Info().Msg(fmt.Sprintf("start to parse %v", path)) + + content, err := os.ReadFile(path) + if err != nil { + log.Error().Err(err).Msg("failed to read file") + return err + } + originalContent := string(content) + + // parse imports + importSlice := t.Regexps.Import.FindAllStringSubmatch(originalContent, -1) + if len(importSlice) != 0 { + imports := strings.Replace(importSlice[0][1], "\t", "", -1) + for _, elem := range strings.Split(imports, "\n") { + t.Imports = append(t.Imports, elem) + } + } else { + if strings.Contains(originalContent, "\nimport ") { + return errors.New(`import style error, expected import ( ... )`) + } + } + // import fungo package + if !builtin.Contains(t.Imports, fungo) { + t.Imports = append(t.Imports, t.Fun) + } + + // parse function name + functionNameSlice := t.Regexps.FunctionName.FindAllStringSubmatch(originalContent, -1) + for _, elem := range functionNameSlice { + name := strings.Trim(elem[1], " ") + if name == "main" { + continue + } + t.FunctionNames = append(t.FunctionNames, name) + t.FunctionSnakeNames = append(t.FunctionSnakeNames, convertSnakeName(name)) + } + + // parse function content + functionContentSlice := t.Regexps.FunctionContent.FindAllStringSubmatch(originalContent, -1) + for _, f := range functionContentSlice { + if strings.Contains(f[0], "func main") { + continue + } + t.Functions = append(t.Functions, strings.Trim(f[0], "\n")) + } + return nil +} + +func (t *TemplateContent) parsePyContent(path string) error { + file, err := os.Open(path) + if err != nil { + fmt.Printf("Error: %s\n", err) + return err + } + defer file.Close() + + r := bufio.NewReader(file) + + // record content excluding import and main + content := "" + + // parse python content line by line + for { + l, _, err := r.ReadLine() + if err == io.EOF { + break + } + line := string(l) + + if strings.HasPrefix(line, "import") { + t.Imports = append(t.Imports, strings.Trim(line, " ")) + } else if strings.HasPrefix(line, "from") { + t.FromImports = append(t.FromImports, strings.Trim(line, " ")) + } else { + // no parse content at under of `if __name__ == "__main__"` + if strings.HasPrefix(line, "if __name__") { + break + } + if strings.HasPrefix(line, "def") { + functionNameSlice := t.Regexps.FunctionName.FindAllStringSubmatch(line, -1) + if len(functionNameSlice) == 0 { + continue + } + t.FunctionNames = append(t.FunctionNames, functionNameSlice[0][1]) + t.FunctionSnakeNames = append(t.FunctionSnakeNames, convertSnakeName(functionNameSlice[0][1])) + } + content += line + "\n" + } + } + // function content + t.Functions = append(t.Functions, strings.Trim(content, "\n")) + + // import funppy + if !builtin.Contains(t.Imports, t.Fun) { + t.Imports = append(t.Imports, t.Fun) + } + return nil +} + +func (t *TemplateContent) genDebugTalk(path string, templ string) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0o666) + if err != nil { + log.Error().Err(err).Msg("open file failed") + return err + } + defer file.Close() + writer := bufio.NewWriter(file) + tmpl := template.Must(template.New("debugtalk").Parse(templ)) + err = tmpl.Execute(writer, t) + if err != nil { + log.Error().Err(err).Msg("execute applies a parsed template to the specified data object failed") + return err + } + err = writer.Flush() + if err == nil { + log.Info().Str("path", path).Msg("generate debugtalk success") + } else { + log.Error().Str("path", path).Msg("generate debugtalk failed") + } + return err +} + +// buildGo builds debugtalk.go to debugtalk.bin +func buildGo(path string) error { + templateContent := &TemplateContent{ + Fun: fungo, + Regexps: &Regexps{ + Import: regexp.MustCompile(regexGoImport), + FunctionName: regexp.MustCompile(regexGoFunctionName), + FunctionContent: regexp.MustCompile(regexGoFunctionContent), + }, + } + dir, _ := filepath.Split(path) + + // create temp dir for building + tempDir, err := ioutil.TempDir("", "hrp_build") + if err != nil { + return err + } + + // check go sdk in tempDir + if err := builtin.ExecCommandInDir(exec.Command("go", "version"), tempDir); err != nil { + return errors.Wrap(err, "go sdk not installed") + } + + // create pluginDir + pluginDir := filepath.Join(tempDir, "plugin") + if err := builtin.CreateFolder(pluginDir); err != nil { + return err + } + // parse debugtalk.go in pluginDir + err = templateContent.parseGoContent(path) + if err != nil { + return err + } + // generate debugtalk.go in pluginDir + err = templateContent.genDebugTalk(filepath.Join(pluginDir, "debugtalk.go"), goTemplate) + if err != nil { + return err + } + + // create go mod + if err := builtin.ExecCommandInDir(exec.Command("go", "mod", "init", "plugin"), pluginDir); err != nil { + return err + } + + // download plugin dependency + // funplugin version should be locked + funplugin := fmt.Sprintf("github.com/httprunner/funplugin@%s", shared.Version) + if err := builtin.ExecCommandInDir(exec.Command("go", "get", funplugin), pluginDir); err != nil { + return err + } + + outputPath, err := filepath.Abs(filepath.Join(dir, "../debugtalk.bin")) + if err != nil { + return err + } + + // build plugin debugtalk.bin + if err := builtin.ExecCommandInDir(exec.Command("go", "build", "-o", outputPath, "debugtalk.go"), pluginDir); err != nil { + return err + } + log.Info().Msg(fmt.Sprintf("build %s to %s successfully", path, outputPath)) + return nil +} + +// buildPy completes funppy information in debugtalk.py +func buildPy(path string) error { + templateContent := &TemplateContent{ + Fun: funppy, + Regexps: &Regexps{ + FunctionName: regexp.MustCompile(regexPythonFunctionName), + }, + } + err := templateContent.parsePyContent(path) + if err != nil { + return err + } + + // generate debugtalk.py + dir, _ := filepath.Split(path) + err = templateContent.genDebugTalk(filepath.Join(dir, "../debugtalk.py"), pyTemplate) + if err != nil { + return err + } + + // ensure funppy in .env + _, err = builtin.EnsurePython3Venv("funppy") + if err != nil { + return err + } + + return nil +} + +func Run(args []string) (err error) { + for _, arg := range args { + ext := filepath.Ext(arg) + switch ext { + case ".py": + err = buildPy(arg) + case ".go": + err = buildGo(arg) + default: + return errors.New("type error, expected .py or .go") + } + if err != nil { + log.Error().Err(err).Msg(fmt.Sprintf("failed to build %s", arg)) + os.Exit(1) + } + } + return nil +} + +// convertSnakeName converts name to snake name +func convertSnakeName(originalName string) string { + snakeName := make([]byte, 0, len(originalName)*2) + flag := false + num := len(originalName) + for i := 0; i < num; i++ { + ch := originalName[i] + if i > 0 && ch >= 'A' && ch <= 'Z' && flag { + snakeName = append(snakeName, '_') + } + if ch != '_' { + flag = true + } + snakeName = append(snakeName, ch) + } + return strings.ToLower(string(snakeName)) +} diff --git a/hrp/internal/build/main_test.go b/hrp/internal/build/main_test.go new file mode 100644 index 00000000..ef9e769f --- /dev/null +++ b/hrp/internal/build/main_test.go @@ -0,0 +1,44 @@ +package build + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRun(t *testing.T) { + err := Run([]string{"examples/debugtalk_no_funppy.py", "examples/debugtalk_no_fungo.go"}) + if !assert.Nil(t, err) { + t.Fatal() + } +} + +func TestConvertSnakeName(t *testing.T) { + testData := []struct { + expectedValue string + originalValue string + }{ + { + expectedValue: "test_name", + originalValue: "testName", + }, + { + expectedValue: "test", + originalValue: "test", + }, + { + expectedValue: "test_name", + originalValue: "TestName", + }, + { + expectedValue: "test_name", + originalValue: "test_name", + }, + } + for _, data := range testData { + name := convertSnakeName(data.originalValue) + if !assert.Equal(t, data.expectedValue, name) { + t.Fatal() + } + } +} diff --git a/hrp/internal/build/templates/debugtalkGoTemplate b/hrp/internal/build/templates/debugtalkGoTemplate new file mode 100644 index 00000000..805d0813 --- /dev/null +++ b/hrp/internal/build/templates/debugtalkGoTemplate @@ -0,0 +1,17 @@ +package main + +import ( +{{- range $import := .Imports }} + {{ $import -}} +{{ end }} +) + +{{ range $function := .Functions }} +{{ $function }} +{{ end }} +func main() { +{{- range $idx, $name := .FunctionNames }} + fungo.Register("{{ index $.FunctionSnakeNames $idx }}", {{ $name }}) +{{- end }} + fungo.Serve() +} diff --git a/hrp/internal/build/templates/debugtalkPythonTemplate b/hrp/internal/build/templates/debugtalkPythonTemplate new file mode 100644 index 00000000..d5b209ec --- /dev/null +++ b/hrp/internal/build/templates/debugtalkPythonTemplate @@ -0,0 +1,15 @@ +{{- range $import := .Imports }} +{{- $import}} +{{ end }} +{{ range $fromImport := .FromImports }} +{{- $fromImport}} +{{ end }} +{{ range $function := .Functions }} +{{ $function }} + +{{ end }} +if __name__ == "__main__": +{{- range $mainRegSnake := .FunctionSnakeNames }} + funppy.register("{{ $mainRegSnake }}", {{ $mainRegSnake }}) +{{- end }} + funppy.serve()