diff --git a/examples/postman_echo/debugtalk.py b/examples/postman_echo/debugtalk.py index 9ec3149b..849bd537 100644 --- a/examples/postman_echo/debugtalk.py +++ b/examples/postman_echo/debugtalk.py @@ -3,3 +3,7 @@ from httprunner import __version__ def get_httprunner_version(): return __version__ + + +def sum_two(m, n): + return m + n diff --git a/examples/postman_echo/request_methods/with_functions.yml b/examples/postman_echo/request_methods/with_functions.yml new file mode 100644 index 00000000..5391a785 --- /dev/null +++ b/examples/postman_echo/request_methods/with_functions.yml @@ -0,0 +1,61 @@ +config: + name: "request methods testcase with functions" + variables: + foo1: session_bar1 + base_url: "https://postman-echo.com" + verify: False + +teststeps: +- + name: get with params + variables: + foo1: bar1 + foo2: session_bar2 + sum_v: "${sum_two(1, 2)}" + request: + method: GET + url: /get + params: + foo1: $foo1 + foo2: $foo2 + sum_v: $sum_v + headers: + User-Agent: HttpRunner/${get_httprunner_version()} + extract: + session_foo2: "body.args.foo2" + validate: + - eq: ["status_code", 200] + - eq: ["body.args.foo1", "session_bar1"] + - eq: ["body.args.foo2", "session_bar2"] + - eq: ["body.args.sum_v", "3"] +- + name: post raw text + variables: + foo1: "hello world" + foo3: "$session_foo2" + request: + method: POST + url: /post + headers: + User-Agent: HttpRunner/${get_httprunner_version()} + Content-Type: "text/plain" + data: "This is expected to be sent back as part of response body: $foo1-$foo3." + validate: + - eq: ["status_code", 200] + - eq: ["body.data", "This is expected to be sent back as part of response body: session_bar1-session_bar2."] +- + name: post form data + variables: + foo1: bar1 + foo2: bar2 + request: + method: POST + url: /post + headers: + User-Agent: HttpRunner/${get_httprunner_version()} + Content-Type: "application/x-www-form-urlencoded" + data: "foo1=$foo1&foo2=$foo2" + validate: + - eq: ["status_code", 200] + - eq: ["body.form.foo1", "session_bar1"] + - eq: ["body.form.foo2", "bar2"] diff --git a/examples/postman_echo/request_methods/with_functions_test.py b/examples/postman_echo/request_methods/with_functions_test.py new file mode 100644 index 00000000..b8bd9126 --- /dev/null +++ b/examples/postman_echo/request_methods/with_functions_test.py @@ -0,0 +1,98 @@ +from httprunner.v3.runner import TestCaseRunner +from httprunner.v3.schema import TestsConfig, TestStep +from examples.postman_echo import debugtalk + + +class TestCaseRequestMethodsWithFunctions(TestCaseRunner): + config = TestsConfig(**{ + "name": "request methods testcase with functions", + "variables": { + "foo1": "session_bar1" + }, + "functions": { + "get_httprunner_version": debugtalk.get_httprunner_version, + "sum_two": debugtalk.sum_two + }, + "base_url": "https://postman-echo.com", + "verify": False + }) + + teststeps = [ + TestStep(**{ + "name": "get with params", + "variables": { + "foo1": "bar1", + "foo2": "session_bar2", + "sum_v": "${sum_two(1, 2)}" + }, + "request": { + "method": "GET", + "url": "/get", + "params": { + "foo1": "$foo1", + "foo2": "$foo2", + "sum_v": "$sum_v" + }, + "headers": { + "User-Agent": "HttpRunner/${get_httprunner_version()}" + } + }, + "extract": { + "session_foo2": "body.args.foo2" + }, + "validate": [ + {"eq": ["status_code", 200]}, + {"eq": ["body.args.foo1", "session_bar1"]}, + {"eq": ["body.args.foo2", "session_bar2"]}, + {"eq": ["body.args.sum_v", "3"]} + ] + }), + TestStep(**{ + "name": "post raw text", + "variables": { + "foo1": "hello world", + "foo3": "$session_foo2" + }, + "request": { + "method": "POST", + "url": "/post", + "data": "This is expected to be sent back as part of response body: $foo1-$foo3.", + "headers": { + "User-Agent": "HttpRunner/${get_httprunner_version()}", + "Content-Type": "text/plain" + } + }, + "validate": [ + {"eq": ["status_code", 200]}, + {"eq": [ + "body.data", + "This is expected to be sent back as part of response body: session_bar1-session_bar2." + ]}, + ] + }), + TestStep(**{ + "name": "post form data", + "variables": { + "foo1": "session_bar1", + "foo2": "bar2" + }, + "request": { + "method": "POST", + "url": "/post", + "data": "foo1=$foo1&foo2=$foo2", + "headers": { + "User-Agent": "HttpRunner/${get_httprunner_version()}", + "Content-Type": "application/x-www-form-urlencoded" + } + }, + "validate": [ + {"eq": ["status_code", 200]}, + {"eq": ["body.form.foo1", "session_bar1"]}, + {"eq": ["body.form.foo2", "bar2"]} + ] + }) + ] + + +if __name__ == '__main__': + TestCaseRequestMethodsWithFunctions().run() diff --git a/httprunner/v3/parser.py b/httprunner/v3/parser.py index f6ff3a58..54a7f349 100644 --- a/httprunner/v3/parser.py +++ b/httprunner/v3/parser.py @@ -1,9 +1,9 @@ +import ast import re -from typing import Any, Set, Text -from typing import Dict +from typing import Any, Set, Text, Callable, Tuple, List, Dict, Union from httprunner.v3 import exceptions -from httprunner.v3.exceptions import VariableNotFound +from httprunner.v3.exceptions import VariableNotFound, FunctionNotFound absolute_http_url_regexp = re.compile(r"^https?://", re.I) @@ -15,6 +15,22 @@ variable_regex_compile = re.compile(r"\$\{(\w+)\}|\$(\w+)") function_regex_compile = re.compile(r"\$\{(\w+)\(([\$\w\.\-/\s=,]*)\)\}") +def parse_string_value(str_value: Text) -> Any: + """ parse string to number if possible + e.g. "123" => 123 + "12.2" => 12.3 + "abc" => "abc" + "$var" => "$var" + """ + try: + return ast.literal_eval(str_value) + except ValueError: + return str_value + except SyntaxError: + # e.g. $var, ${func} + return str_value + + def build_url(base_url, path): """ prepend url with base_url unless it's already an absolute URL """ if absolute_http_url_regexp.match(path): @@ -25,7 +41,7 @@ def build_url(base_url, path): raise exceptions.ParamsError("base url missed!") -def regex_findall_variables(content): +def regex_findall_variables(content: Text) -> List[Text]: """ extract all variable names from content, which is in format $variable Args: @@ -59,6 +75,88 @@ def regex_findall_variables(content): return [] +def regex_findall_functions(content: Text) -> List[Text]: + """ extract all functions from string content, which are in format ${fun()} + + Args: + content (str): string content + + Returns: + list: functions list extracted from string content + + Examples: + >>> regex_findall_functions("${func(5)}") + ["func(5)"] + + >>> regex_findall_functions("${func(a=1, b=2)}") + ["func(a=1, b=2)"] + + >>> regex_findall_functions("/api/1000?_t=${get_timestamp()}") + ["get_timestamp()"] + + >>> regex_findall_functions("/api/${add(1, 2)}") + ["add(1, 2)"] + + >>> regex_findall_functions("/api/${add(1, 2)}?_t=${get_timestamp()}") + ["add(1, 2)", "get_timestamp()"] + + """ + try: + return function_regex_compile.findall(content) + except TypeError: + return [] + + +def parse_args_str(arg_str: Text) -> Tuple[List, Dict]: + """ parse function args and kwargs from function. + + Args: + arg_str (str): function str contains args and kwargs + + Returns: + dict: function meta dict + + { + "func_name": "xxx", + "args": [], + "kwargs": {} + } + + Examples: + >>> parse_args_str("") + {'args': [], 'kwargs': {}} + + >>> parse_args_str("5") + {'args': [5], 'kwargs': {}} + + >>> parse_args_str("1, 2") + {'args': [1, 2], 'kwargs': {}} + + >>> parse_args_str("a=1, b=2") + {'args': [], 'kwargs': {'a': 1, 'b': 2}} + + >>> parse_args_str("1, 2, a=3, b=4") + {'args': [1, 2], 'kwargs': {'a':3, 'b':4}} + + """ + args = [] + kwargs = {} + arg_str = arg_str.strip() + if arg_str == "": + return args, kwargs + + arg_list = arg_str.split(',') + for arg in arg_list: + arg = arg.strip() + if '=' in arg: + key, value = arg.split('=') + kwargs[key.strip()] = parse_string_value(value.strip()) + else: + args.append(parse_string_value(arg)) + + return args, kwargs + + def extract_variables(content: Any) -> Set: """ extract all variables in content recursively. """ @@ -80,7 +178,59 @@ def extract_variables(content: Any) -> Set: return set() -def parse_string_variables(content, variables_mapping): +def parse_string_functions( + content: Text, + variables_mapping: Dict[Text, Any], + functions_mapping: Dict[Text, Callable]) -> Text: + """ parse string content with functions mapping. + + Args: + content (str): string content to be parsed. + variables_mapping (dict): variables mapping. + functions_mapping (dict): functions mapping. + + Returns: + str: parsed string content. + + Examples: + >>> content = "abc${add_one(3)}def" + >>> functions_mapping = {"add_one": lambda x: x + 1} + >>> parse_string_functions(content, {}, functions_mapping) + "abc4def" + + """ + functions_list = regex_findall_functions(content) + for func_meta_tuple in functions_list: + func_name, args_str = func_meta_tuple + args, kwargs = parse_args_str(args_str) + + args = parse_content(args, variables_mapping, functions_mapping) + kwargs = parse_content(kwargs, variables_mapping, functions_mapping) + + try: + func = functions_mapping[func_name] + except KeyError: + raise FunctionNotFound(f"{func_name} not found in {functions_mapping}") + + eval_value = func(*args, **kwargs) + + func_content = "${" + func_name + f"({args_str})" + "}" + if func_content == content: + # content is a function, e.g. "${add_one(3)}" + content = eval_value + else: + # content contains one or many functions, e.g. "abc${add_one(3)}def" + content = content.replace( + func_content, + str(eval_value), 1 + ) + + return content + + +def parse_string_variables( + content: Text, + variables_mapping: Dict[Text, Any]) -> Text: """ parse string content with variables mapping. Args: @@ -92,7 +242,7 @@ def parse_string_variables(content, variables_mapping): Examples: >>> content = "/api/users/$uid" - >>> variables_mapping = {"$uid": 1000} + >>> variables_mapping = {"uid": 1000} >>> parse_string_variables(content, variables_mapping) "/api/users/1000" @@ -102,7 +252,7 @@ def parse_string_variables(content, variables_mapping): try: variable_value = variables_mapping[variable_name] except KeyError: - raise VariableNotFound(f"{variable_name} not in {variables_mapping}") + raise VariableNotFound(f"{variable_name} not found in {variables_mapping}") # TODO: replace variable label from $var to {{var}} if f"${variable_name}" == content: @@ -121,7 +271,10 @@ def parse_string_variables(content, variables_mapping): return content -def parse_content(content: Any, variables_mapping: Dict[str, Any] = None, functions_mapping=None): +def parse_content( + content: Any, + variables_mapping: Dict[Text, Any] = None, + functions_mapping: Dict[Text, Callable] = None) -> Any: """ parse content with evaluated variables mapping. Notice: variables_mapping should not contain any variable or function. """ @@ -136,8 +289,8 @@ def parse_content(content: Any, variables_mapping: Dict[str, Any] = None, functi content = content.strip() # replace functions with evaluated value - # Notice: _eval_content_functions must be called before _eval_content_variables - # content = parse_string_functions(content, variables_mapping, functions_mapping) + # Notice: parse_string_functions must be called before parse_string_variables + content = parse_string_functions(content, variables_mapping, functions_mapping) # replace variables with binding value content = parse_string_variables(content, variables_mapping) @@ -146,15 +299,15 @@ def parse_content(content: Any, variables_mapping: Dict[str, Any] = None, functi elif isinstance(content, (list, set, tuple)): return [ - parse_content(item, variables_mapping) + parse_content(item, variables_mapping, functions_mapping) for item in content ] elif isinstance(content, dict): parsed_content = {} for key, value in content.items(): - parsed_key = parse_content(key, variables_mapping) - parsed_value = parse_content(value, variables_mapping) + parsed_key = parse_content(key, variables_mapping, functions_mapping) + parsed_value = parse_content(value, variables_mapping, functions_mapping) parsed_content[parsed_key] = parsed_value return parsed_content @@ -162,7 +315,9 @@ def parse_content(content: Any, variables_mapping: Dict[str, Any] = None, functi return content -def parse_variables_mapping(variables_mapping: Dict[Text, Any]) -> Dict[Text, Any]: +def parse_variables_mapping( + variables_mapping: Dict[Text, Any], + functions_mapping: Dict[Text, Callable] = None) -> Dict[Text, Any]: parsed_variables: Dict[Text, Any] = {} @@ -194,7 +349,8 @@ def parse_variables_mapping(variables_mapping: Dict[Text, Any]) -> Dict[Text, An raise VariableNotFound(not_defined_variables) try: - parsed_value = parse_content(var_value, parsed_variables) + parsed_value = parse_content( + var_value, parsed_variables, functions_mapping) except VariableNotFound: continue diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index 12d28059..76277225 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -27,7 +27,7 @@ class TestCaseRunner(object): # parse request_dict = step.request.dict() - parsed_request_dict = parse_content(request_dict, step.variables) + parsed_request_dict = parse_content(request_dict, step.variables, self.config.functions) # prepare arguments method = parsed_request_dict.pop("method") @@ -62,7 +62,7 @@ class TestCaseRunner(object): # update with session variables extracted from former step step.variables.update(session_variables) # parse variables - step.variables = parse_variables_mapping(step.variables) + step.variables = parse_variables_mapping(step.variables, self.config.functions) # run step extract_mapping = self.run_step(step) # save extracted variables to session variables diff --git a/httprunner/v3/schema/__init__.py b/httprunner/v3/schema/__init__.py index 778a217a..ea1220d2 100644 --- a/httprunner/v3/schema/__init__.py +++ b/httprunner/v3/schema/__init__.py @@ -1,6 +1,6 @@ from enum import Enum from typing import Any -from typing import Dict, List, Text, Union +from typing import Dict, List, Text, Union, Callable from pydantic import BaseModel, Field from pydantic import HttpUrl @@ -34,6 +34,7 @@ class TestsConfig(BaseModel): verify: Verify = False base_url: BaseUrl = "" variables: Variables = {} + functions: Dict[Text, Callable] setup_hooks: Hook = [] teardown_hooks: Hook = [] export: Export = []