diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/postman_echo/__init__.py b/examples/postman_echo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/postman_echo/request_methods/__init__.py b/examples/postman_echo/request_methods/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/postman_echo/request_methods/hardcode.py b/examples/postman_echo/request_methods/hardcode.py new file mode 100644 index 00000000..d1129ab6 --- /dev/null +++ b/examples/postman_echo/request_methods/hardcode.py @@ -0,0 +1,79 @@ +from httprunner.v3.runner import TestCaseRunner +from httprunner.v3.schema import TestsConfig, TestStep + + +class TestCaseRequestMethodsHardcode(TestCaseRunner): + config = TestsConfig(**{ + "name": "request methods testcase in hardcode", + "base_url": "https://postman-echo.com", + "verify": False + }) + + teststeps = [ + TestStep(**{ + "name": "get with params", + "request": { + "method": "GET", + "url": "/get", + "params": { + "foo1": "bar1", + "foo2": "bar2" + }, + "headers": { + "User-Agent": "HttpRunner/3.0" + } + }, + "validate": [ + {"eq": ["status_code", 200]} + ] + }), + TestStep(**{ + "name": "post raw text", + "request": { + "method": "POST", + "url": "/post", + "data": "This is expected to be sent back as part of response body.", + "headers": { + "User-Agent": "HttpRunner/3.0", + "Content-Type": "text/plain" + } + }, + "validate": [ + {"eq": ["status_code", 200]} + ] + }), + TestStep(**{ + "name": "post form data", + "request": { + "method": "POST", + "url": "/post", + "data": "foo1=bar1&foo2=bar2", + "headers": { + "User-Agent": "HttpRunner/3.0", + "Content-Type": "application/x-www-form-urlencoded" + } + }, + "validate": [ + {"eq": ["status_code", 200]} + ] + }), + TestStep(**{ + "name": "put request", + "request": { + "method": "PUT", + "url": "/put", + "data": "This is expected to be sent back as part of response body.", + "headers": { + "User-Agent": "HttpRunner/3.0", + "Content-Type": "text/plain" + } + }, + "validate": [ + {"eq": ["status_code", 200]} + ] + }) + ] + + +if __name__ == '__main__': + TestCaseRequestMethodsHardcode().run() diff --git a/httprunner/v3/__init__.py b/httprunner/v3/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/httprunner/v3/exceptions/__init__.py b/httprunner/v3/exceptions/__init__.py new file mode 100644 index 00000000..77d1be52 --- /dev/null +++ b/httprunner/v3/exceptions/__init__.py @@ -0,0 +1,81 @@ +""" failure type exceptions + these exceptions will mark test as failure +""" + + +class MyBaseFailure(Exception): + pass + + +class ParseTestsFailure(MyBaseFailure): + pass + + +class ValidationFailure(MyBaseFailure): + pass + + +class ExtractFailure(MyBaseFailure): + pass + + +class SetupHooksFailure(MyBaseFailure): + pass + + +class TeardownHooksFailure(MyBaseFailure): + pass + + +""" error type exceptions + these exceptions will mark test as error +""" + + +class MyBaseError(Exception): + pass + + +class FileFormatError(MyBaseError): + pass + + +class ParamsError(MyBaseError): + pass + + +class NotFoundError(MyBaseError): + pass + + +class FileNotFound(FileNotFoundError, NotFoundError): + pass + + +class FunctionNotFound(NotFoundError): + pass + + +class VariableNotFound(NotFoundError): + pass + + +class EnvNotFound(NotFoundError): + pass + + +class CSVNotFound(NotFoundError): + pass + + +class ApiNotFound(NotFoundError): + pass + + +class TestcaseNotFound(NotFoundError): + pass + + +class SummaryEmpty(MyBaseError): + """ test result summary data is empty + """ diff --git a/httprunner/v3/parser/__init__.py b/httprunner/v3/parser/__init__.py new file mode 100644 index 00000000..1a7803a1 --- /dev/null +++ b/httprunner/v3/parser/__init__.py @@ -0,0 +1,178 @@ +import re +from typing import Any, Set +from typing import Dict + +from httprunner.v3.exceptions import ParamsError + +absolute_http_url_regexp = re.compile(r"^https?://", re.I) + +# use $$ to escape $ notation +dolloar_regex_compile = re.compile(r"\$\$") +# variable notation, e.g. ${var} or $var +variable_regex_compile = re.compile(r"\$\{(\w+)\}|\$(\w+)") +# function notation, e.g. ${func1($var_1, $var_3)} +function_regex_compile = re.compile(r"\$\{(\w+)\(([\$\w\.\-/\s=,]*)\)\}") + + +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): + return path + elif base_url: + return "{}/{}".format(base_url.rstrip("/"), path.lstrip("/")) + else: + raise ParamsError("base url missed!") + + +def regex_findall_variables(content): + """ extract all variable names from content, which is in format $variable + + Args: + content (str): string content + + Returns: + list: variables list extracted from string content + + Examples: + >>> regex_findall_variables("$variable") + ["variable"] + + >>> regex_findall_variables("/blog/$postid") + ["postid"] + + >>> regex_findall_variables("/$var1/$var2") + ["var1", "var2"] + + >>> regex_findall_variables("abc") + [] + + """ + try: + vars_list = [] + for var_tuple in variable_regex_compile.findall(content): + vars_list.append( + var_tuple[0] or var_tuple[1] + ) + return vars_list + except TypeError: + return [] + + + +def extract_variables(content: Any) -> Set: + """ extract all variables in content recursively. + """ + if isinstance(content, (list, set, tuple)): + variables = set() + for item in content: + variables = variables | extract_variables(item) + return variables + + elif isinstance(content, dict): + variables = set() + for key, value in content.items(): + variables = variables | extract_variables(value) + return variables + + elif isinstance(content, str): + return set(regex_findall_variables(content)) + + return set() + + +def parse_string_variables(content, variables_mapping): + """ parse string content with variables mapping. + + Args: + content (str): string content to be parsed. + variables_mapping (dict): variables mapping. + + Returns: + str: parsed string content. + + Examples: + >>> content = "/api/users/$uid" + >>> variables_mapping = {"$uid": 1000} + >>> parse_string_variables(content, variables_mapping) + "/api/users/1000" + + """ + variables_list = extract_variables(content) + for variable_name in variables_list: + variable_value = variables_mapping[variable_name] + + # TODO: replace variable label from $var to {{var}} + if "${}".format(variable_name) == content: + # content is a variable + content = variable_value + else: + # content contains one or several variables + if not isinstance(variable_value, str): + variable_value = str(variable_value) + + content = content.replace( + "${}".format(variable_name), + variable_value, 1 + ) + + return content + + +def parse_content(content: Any, variables_mapping: Dict[str, Any] = None, functions_mapping=None): + """ parse content with evaluated variables mapping. + Notice: variables_mapping should not contain any variable or function. + """ + # TODO: refactor type check + if content is None or isinstance(content, (int, float, bool)): + return content + + elif isinstance(content, str): + # content is in string format here + variables_mapping = variables_mapping or {} + functions_mapping = functions_mapping or {} + 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) + + # replace variables with binding value + content = parse_string_variables(content, variables_mapping) + + return content + + elif isinstance(content, (list, set, tuple)): + return [ + parse_content(item, variables_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_content[parsed_key] = parsed_value + + return parsed_content + + return content + + +def parse_variables_mapping(variables_mapping: Dict[str, Any]): + + parsed_variables: Dict[str, Any] = {} + + while len(parsed_variables) != len(variables_mapping): + for var_name in variables_mapping: + + var_value = variables_mapping[var_name] + # variables = extract_variables(var_value) + + if var_name in parsed_variables: + continue + + parsed_value = parse_content(var_value, parsed_variables) + parsed_variables[var_name] = parsed_value + + return parsed_variables diff --git a/httprunner/v3/runner/__init__.py b/httprunner/v3/runner/__init__.py new file mode 100644 index 00000000..61638567 --- /dev/null +++ b/httprunner/v3/runner/__init__.py @@ -0,0 +1,38 @@ +from typing import List + +import requests + +from httprunner.v3.parser import build_url +from httprunner.v3.schema import TestsConfig, TestStep + + +class TestCaseRunner(object): + + config: TestsConfig = {} + teststeps: List[TestStep] = [] + session: requests.Session = None + + def with_session(self, s: requests.Session) -> "TestCaseRunner": + self.session = s + return self + + def with_variables(self, **variables) -> "TestCaseRunner": + self.config.variables.update(variables) + return self + + def run_step(self, step): + request_dict = step.request.dict() + + method = request_dict.pop("method") + url_path = request_dict.pop("url") + url = build_url(self.config.base_url, url_path) + + request_dict["json"] = request_dict.pop("req_json", {}) + + session = self.session or requests.Session() + resp = session.request(method, url, **request_dict) + + def run(self): + for step in self.teststeps: + step.variables.update(self.config.variables) + self.run_step(step) diff --git a/httprunner/v3/schema/__init__.py b/httprunner/v3/schema/__init__.py new file mode 100644 index 00000000..fcaac363 --- /dev/null +++ b/httprunner/v3/schema/__init__.py @@ -0,0 +1,60 @@ +from enum import Enum +from typing import Any +from typing import Dict, List, Text, Union + +from pydantic import BaseModel, Field +from pydantic import HttpUrl + +Name = Text +Url = Text +BaseUrl = Union[HttpUrl, Text] +Variables = Dict[Text, Any] +Headers = Dict[Text, Text] +Verify = bool +Hook = List[Text] +Export = List[Text] +Validate = List[Dict] +Env = Dict[Text, Any] + + +class MethodEnum(Text, Enum): + GET = 'GET' + POST = 'POST' + PUT = "PUT" + DELETE = "DELETE" + HEAD = "HEAD" + OPTIONS = "OPTIONS" + PATCH = "PATCH" + CONNECT = "CONNECT" + TRACE = "TRACE" + + +class TestsConfig(BaseModel): + name: Name + verify: Verify = False + base_url: BaseUrl = "" + variables: Variables = {} + setup_hooks: Hook = [] + teardown_hooks: Hook = [] + export: Export = [] + + +class Request(BaseModel): + method: MethodEnum = MethodEnum.GET + url: Url + params: Dict[Text, Text] = {} + headers: Headers = {} + req_json: Dict = Field({}, alias="json") + data: Union[Text, Dict[Text, Any]] = "" + cookies: Dict[Text, Text] = {} + timeout: int = 120 + allow_redirects: bool = True + verify: Verify = False + + +class TestStep(BaseModel): + name: Name + request: Request + variables: Variables = {} + extract: Union[Dict[Text, Text], List[Text]] = {} + validation: Validate = Field([], alias="validate")