Merge pull request #924 from httprunner/v3

## 3.0.10 (2020-06-07)

**Added**

- feat: implement step setup/teardown hooks
- feat: support alter response in teardown hooks

**Fixed**

- fix: ensure upload ready
- fix: add ExtendJSONEncoder to safely dump json data with python object, such as MultipartEncoder
This commit is contained in:
debugtalk
2020-06-08 10:44:14 +08:00
committed by GitHub
27 changed files with 220 additions and 57 deletions

View File

@@ -1,5 +1,17 @@
# Release History # Release History
## 3.0.10 (2020-06-07)
**Added**
- feat: implement step setup/teardown hooks
- feat: support alter response in teardown hooks
**Fixed**
- fix: ensure upload ready
- fix: add ExtendJSONEncoder to safely dump json data with python object, such as MultipartEncoder
## 3.0.9 (2020-06-07) ## 3.0.9 (2020-06-07)
**Fixed** **Fixed**

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.0.9 # NOTE: Generated By HttpRunner v3.0.10
# FROM: examples/httpbin/basic.yml # FROM: examples/httpbin/basic.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -126,7 +126,7 @@ def setup_hook_httpntlmauth(request):
def alter_response(response): def alter_response(response):
response.status_code = 500 response.status_code = 500
response.headers["Content-Type"] = "html/text" response.headers["Content-Type"] = "html/text"
response.json["headers"]["Host"] = "127.0.0.1:8888" response.body["headers"]["Host"] = "127.0.0.1:8888"
response.new_attribute = "new_attribute_value" response.new_attribute = "new_attribute_value"
response.new_attribute_dict = {"key": 123} response.new_attribute_dict = {"key": 123}

View File

@@ -31,7 +31,7 @@ teststeps:
teardown_hooks: teardown_hooks:
- ${alter_response($response)} - ${alter_response($response)}
validate: validate:
- eq: ["status_code", 200] - eq: ["status_code", 500]
# TODO: implement hooks - eq: [headers."Content-Type", "html/text"]
# - eq: [body.headers."Content-Type", "html/text"] - eq: [body.headers."Content-Type", "application/json"]
- eq: [body.headers.Host, "httpbin.org"] - eq: [body.headers.Host, "127.0.0.1:8888"]

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.0.9 # NOTE: Generated By HttpRunner v3.0.10
# FROM: examples/httpbin/hooks.yml # FROM: examples/httpbin/hooks.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase
@@ -11,7 +11,10 @@ class TestCaseHooks(HttpRunner):
Step( Step(
RunRequest("headers") RunRequest("headers")
.with_variables(**{"a": 123}) .with_variables(**{"a": 123})
.setup_hook("${setup_hook_add_kwargs($request)}")
.setup_hook("${setup_hook_remove_kwargs($request)}")
.get("/headers") .get("/headers")
.teardown_hook("${teardown_hook_sleep_N_secs($response, 1)}")
.validate() .validate()
.assert_equal("status_code", 200) .assert_equal("status_code", 200)
.assert_contained_by("body.headers.Host", "${get_httpbin_server()}") .assert_contained_by("body.headers.Host", "${get_httpbin_server()}")
@@ -19,9 +22,12 @@ class TestCaseHooks(HttpRunner):
Step( Step(
RunRequest("alter response") RunRequest("alter response")
.get("/headers") .get("/headers")
.teardown_hook("${alter_response($response)}")
.validate() .validate()
.assert_equal("status_code", 200) .assert_equal("status_code", 500)
.assert_equal("body.headers.Host", "httpbin.org") .assert_equal('headers."Content-Type"', "html/text")
.assert_equal('body.headers."Content-Type"', "application/json")
.assert_equal("body.headers.Host", "127.0.0.1:8888")
), ),
] ]

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.0.9 # NOTE: Generated By HttpRunner v3.0.10
# FROM: examples/httpbin/load_image.yml # FROM: examples/httpbin/load_image.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.0.9 # NOTE: Generated By HttpRunner v3.0.10
# FROM: examples/httpbin/upload.yml # FROM: examples/httpbin/upload.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.0.9 # NOTE: Generated By HttpRunner v3.0.10
# FROM: examples/httpbin/validate.yml # FROM: examples/httpbin/validate.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.0.9 # NOTE: Generated By HttpRunner v3.0.10
# FROM: examples/postman_echo/request_methods/request_with_functions.yml # FROM: examples/postman_echo/request_methods/request_with_functions.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.0.9 # NOTE: Generated By HttpRunner v3.0.10
# FROM: examples/postman_echo/request_methods/request_with_testcase_reference.yml # FROM: examples/postman_echo/request_methods/request_with_testcase_reference.yml
import os import os

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.0.9 # NOTE: Generated By HttpRunner v3.0.10
# FROM: examples/postman_echo/request_methods/hardcode.yml # FROM: examples/postman_echo/request_methods/hardcode.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.0.9 # NOTE: Generated By HttpRunner v3.0.10
# FROM: examples/postman_echo/request_methods/request_with_functions.yml # FROM: examples/postman_echo/request_methods/request_with_functions.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.0.9 # NOTE: Generated By HttpRunner v3.0.10
# FROM: examples/postman_echo/request_methods/request_with_testcase_reference.yml # FROM: examples/postman_echo/request_methods/request_with_testcase_reference.yml
import os import os

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.0.9 # NOTE: Generated By HttpRunner v3.0.10
# FROM: examples/postman_echo/request_methods/request_with_variables.yml # FROM: examples/postman_echo/request_methods/request_with_variables.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.0.9 # NOTE: Generated By HttpRunner v3.0.10
# FROM: examples/postman_echo/request_methods/validate_with_functions.yml # FROM: examples/postman_echo/request_methods/validate_with_functions.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.0.9 # NOTE: Generated By HttpRunner v3.0.10
# FROM: examples/postman_echo/request_methods/validate_with_variables.yml # FROM: examples/postman_echo/request_methods/validate_with_variables.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -1,4 +1,4 @@
__version__ = "3.0.9" __version__ = "3.0.10"
__description__ = "One-stop solution for HTTP(S) testing." __description__ = "One-stop solution for HTTP(S) testing."
from httprunner.runner import HttpRunner from httprunner.runner import HttpRunner

View File

@@ -264,7 +264,7 @@ import time
import pytest import pytest
from loguru import logger from loguru import logger
from httprunner.utils import get_platform from httprunner.utils import get_platform, ExtendJSONEncoder
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
@@ -316,7 +316,7 @@ def session_fixture(request):
os.makedirs(summary_dir, exist_ok=True) os.makedirs(summary_dir, exist_ok=True)
with open(summary_path, "w", encoding="utf-8") as f: with open(summary_path, "w", encoding="utf-8") as f:
json.dump(summary, f, indent=4) json.dump(summary, f, indent=4, ensure_ascii=False, cls=ExtendJSONEncoder)
logger.info(f"generated task summary: {summary_path}") logger.info(f"generated task summary: {summary_path}")

View File

@@ -60,6 +60,22 @@ except ModuleNotFoundError:
UPLOAD_READY = False UPLOAD_READY = False
def ensure_upload_ready():
if UPLOAD_READY:
return
msg = """
uploader extension dependencies uninstalled, install first and try again.
install with pip:
$ pip install requests_toolbelt filetype
or you can install httprunner with optional upload dependencies:
$ pip install "httprunner[upload]"
"""
logger.error(msg)
sys.exit(1)
def prepare_upload_step(step: TStep, functions: FunctionsMapping) -> "NoReturn": def prepare_upload_step(step: TStep, functions: FunctionsMapping) -> "NoReturn":
""" preprocess for upload test """ preprocess for upload test
replace `upload` info with MultipartEncoder replace `upload` info with MultipartEncoder
@@ -86,15 +102,7 @@ def prepare_upload_step(step: TStep, functions: FunctionsMapping) -> "NoReturn":
if not step.request.upload: if not step.request.upload:
return return
if not UPLOAD_READY: ensure_upload_ready()
msg = """
uploader extension dependencies uninstalled, install first and try again.
install with pip:
$ pip install requests_toolbelt filetype
"""
logger.error(msg)
sys.exit(1)
params_list = [] params_list = []
for key, value in step.request.upload.items(): for key, value in step.request.upload.items():
step.variables[key] = value step.variables[key] = value
@@ -126,6 +134,7 @@ def multipart_encoder(**kwargs):
else: else:
return "text/html" return "text/html"
ensure_upload_ready()
fields_dict = {} fields_dict = {}
for key, value in kwargs.items(): for key, value in kwargs.items():
@@ -165,4 +174,5 @@ def multipart_content_type(m_encoder) -> Text:
content type content type
""" """
ensure_upload_ready()
return m_encoder.content_type return m_encoder.content_type

View File

@@ -228,6 +228,17 @@ def make_teststep_chain_style(teststep: Dict) -> Text:
variables = teststep["variables"] variables = teststep["variables"]
step_info += f".with_variables(**{variables})" step_info += f".with_variables(**{variables})"
if "setup_hooks" in teststep:
setup_hooks = teststep["setup_hooks"]
for hook in setup_hooks:
if isinstance(hook, Text):
step_info += f'.setup_hook("{hook}")'
elif isinstance(hook, Dict) and len(hook) == 1:
assign_var_name, hook_content = list(hook.items())[0]
step_info += f'.setup_hook("{hook}", "{assign_var_name}")'
else:
raise exceptions.TestCaseFormatError(f"Invalid setup hook: {hook}")
if teststep.get("request"): if teststep.get("request"):
step_info += make_request_chain_style(teststep["request"]) step_info += make_request_chain_style(teststep["request"])
elif teststep.get("testcase"): elif teststep.get("testcase"):
@@ -235,6 +246,17 @@ def make_teststep_chain_style(teststep: Dict) -> Text:
call_ref_testcase = f".call({testcase})" call_ref_testcase = f".call({testcase})"
step_info += call_ref_testcase step_info += call_ref_testcase
if "teardown_hooks" in teststep:
teardown_hooks = teststep["teardown_hooks"]
for hook in teardown_hooks:
if isinstance(hook, Text):
step_info += f'.teardown_hook("{hook}")'
elif isinstance(hook, Dict) and len(hook) == 1:
assign_var_name, hook_content = list(hook.items())[0]
step_info += f'.teardown_hook("{hook}", "{assign_var_name}")'
else:
raise exceptions.TestCaseFormatError(f"Invalid teardown hook: {hook}")
if "extract" in teststep: if "extract" in teststep:
# request step # request step
step_info += ".extract()" step_info += ".extract()"

View File

@@ -15,7 +15,7 @@ FunctionsMapping = Dict[Text, Callable]
Headers = Dict[Text, Text] Headers = Dict[Text, Text]
Cookies = Dict[Text, Text] Cookies = Dict[Text, Text]
Verify = bool Verify = bool
Hook = List[Text] Hooks = List[Union[Text, Dict[Text, Text]]]
Export = List[Text] Export = List[Text]
Validators = List[Dict] Validators = List[Dict]
Env = Dict[Text, Any] Env = Dict[Text, Any]
@@ -37,8 +37,8 @@ class TConfig(BaseModel):
base_url: BaseUrl = "" base_url: BaseUrl = ""
# Text: prepare variables in debugtalk.py, ${gen_variables()} # Text: prepare variables in debugtalk.py, ${gen_variables()}
variables: Union[VariablesMapping, Text] = {} variables: Union[VariablesMapping, Text] = {}
setup_hooks: Hook = [] # setup_hooks: Hooks = []
teardown_hooks: Hook = [] # teardown_hooks: Hooks = []
export: Export = [] export: Export = []
path: Text = None path: Text = None
@@ -64,8 +64,8 @@ class TStep(BaseModel):
request: Union[TRequest, None] = None request: Union[TRequest, None] = None
testcase: Union[Text, Callable, None] = None testcase: Union[Text, Callable, None] = None
variables: VariablesMapping = {} variables: VariablesMapping = {}
setup_hooks: Hook = [] setup_hooks: Hooks = []
teardown_hooks: Hook = [] teardown_hooks: Hooks = []
# used to extract request's response field # used to extract request's response field
extract: VariablesMapping = {} extract: VariablesMapping = {}
# used to export session variables from referenced testcase # used to export session variables from referenced testcase

View File

@@ -4,6 +4,7 @@ import jmespath
import requests import requests
from loguru import logger from loguru import logger
from httprunner import exceptions
from httprunner.exceptions import ValidationFailure, ParamsError from httprunner.exceptions import ValidationFailure, ParamsError
from httprunner.parser import parse_data, parse_string_value, get_mapping_function from httprunner.parser import parse_data, parse_string_value, get_mapping_function
from httprunner.models import VariablesMapping, Validators, FunctionsMapping from httprunner.models import VariablesMapping, Validators, FunctionsMapping
@@ -109,20 +110,36 @@ class ResponseObject(object):
""" """
self.resp_obj = resp_obj self.resp_obj = resp_obj
try:
body = resp_obj.json()
except ValueError:
body = resp_obj.content
self.resp_obj_meta = {
"status_code": resp_obj.status_code,
"headers": resp_obj.headers,
"cookies": dict(resp_obj.cookies),
"body": body,
}
self.validation_results: Dict = {} self.validation_results: Dict = {}
def __getattr__(self, key):
if key in ["json", "content", "body"]:
try:
value = self.resp_obj.json()
except ValueError:
value = self.resp_obj.content
elif key == "cookies":
value = self.resp_obj.cookies.get_dict()
else:
try:
value = getattr(self.resp_obj, key)
except AttributeError:
err_msg = "ResponseObject does not have attribute: {}".format(key)
logger.error(err_msg)
raise exceptions.ParamsError(err_msg)
self.__dict__[key] = value
return value
@property
def resp_obj_meta(self):
return {
"status_code": self.status_code,
"headers": self.headers,
"cookies": self.cookies,
"body": self.body,
}
def extract(self, extractors: Dict[Text, Text]) -> Dict[Text, Any]: def extract(self, extractors: Dict[Text, Text]) -> Dict[Text, Any]:
if not extractors: if not extractors:
return {} return {}

View File

@@ -2,7 +2,7 @@ import os
import time import time
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import List, Dict, Text, NoReturn from typing import List, Dict, Text, NoReturn, Union
try: try:
import allure import allure
@@ -31,6 +31,7 @@ from httprunner.models import (
TestCaseInOut, TestCaseInOut,
ProjectMeta, ProjectMeta,
TestCase, TestCase,
Hooks,
) )
@@ -44,7 +45,7 @@ class HttpRunner(object):
__project_meta: ProjectMeta = None __project_meta: ProjectMeta = None
__case_id: Text = "" __case_id: Text = ""
__export: List[Text] = [] __export: List[Text] = []
__step_datas: List[StepData] = None __step_datas: List[StepData] = []
__session: HttpSession = None __session: HttpSession = None
__session_variables: VariablesMapping = {} __session_variables: VariablesMapping = {}
# time # time
@@ -86,6 +87,52 @@ class HttpRunner(object):
self.__export = export self.__export = export
return self return self
def __call_hooks(
self, hooks: Hooks, step_variables: VariablesMapping, hook_type: Text,
) -> NoReturn:
""" call hook actions.
Args:
hooks (list): each hook in hooks list maybe in two format.
format1 (str): only call hook functions.
${func()}
format2 (dict): assignment, the value returned by hook function will be assigned to variable.
{"var": "${func()}"}
step_variables: current step variables to call hook, include two special variables
request: parsed request dict
response: ResponseObject for current response
hook_type: setup/teardown
"""
logger.debug(f"call {hook_type} hook actions.")
if not isinstance(hooks, List):
logger.error(f"Invalid hooks format: {hooks}")
return
for hook in hooks:
if isinstance(hook, Text):
# format 1: ["${func()}"]
logger.debug(f"call hook function: {hook}")
parse_data(hook, step_variables, self.__project_meta.functions)
elif isinstance(hook, Dict) and len(hook) == 1:
# format 2: {"var": "${func()}"}
var_name, hook_content = list(hook.items())[0]
hook_content_eval = parse_data(
hook_content, step_variables, self.__project_meta.functions
)
logger.debug(
f"call hook function: {hook_content}, got value: {hook_content_eval}"
)
logger.debug(f"assign variable: {var_name} = {hook_content_eval}")
step_variables[var_name] = hook_content_eval
else:
logger.error(f"Invalid hook format: {hook}")
def __run_step_request(self, step: TStep) -> StepData: def __run_step_request(self, step: TStep) -> StepData:
"""run teststep: request""" """run teststep: request"""
step_data = StepData(name=step.name) step_data = StepData(name=step.name)
@@ -101,6 +148,11 @@ class HttpRunner(object):
"HRUN-Request-ID", "HRUN-Request-ID",
f"HRUN-{self.__case_id}-{str(int(time.time() * 1000))[-6:]}", f"HRUN-{self.__case_id}-{str(int(time.time() * 1000))[-6:]}",
) )
step.variables["request"] = parsed_request_dict
# setup hooks
if step.setup_hooks:
self.__call_hooks(step.setup_hooks, step.variables, "setup")
# prepare arguments # prepare arguments
method = parsed_request_dict.pop("method") method = parsed_request_dict.pop("method")
@@ -112,6 +164,11 @@ class HttpRunner(object):
# request # request
resp = self.__session.request(method, url, **parsed_request_dict) resp = self.__session.request(method, url, **parsed_request_dict)
resp_obj = ResponseObject(resp) resp_obj = ResponseObject(resp)
step.variables["response"] = resp_obj
# teardown hooks
if step.teardown_hooks:
self.__call_hooks(step.teardown_hooks, step.variables, "teardown")
def log_req_resp_details(): def log_req_resp_details():
err_msg = "\n{} DETAILED REQUEST & RESPONSE {}\n".format("*" * 32, "*" * 32) err_msg = "\n{} DETAILED REQUEST & RESPONSE {}\n".format("*" * 32, "*" * 32)

View File

@@ -265,8 +265,15 @@ class RequestWithOptionalArgs(object):
self.__step_context.request.upload.update(file_info) self.__step_context.request.upload.update(file_info)
return self return self
# def hooks(self): def teardown_hook(
# pass self, hook: Text, assign_var_name: Text = None
) -> "RequestWithOptionalArgs":
if assign_var_name:
self.__step_context.teardown_hooks.append({assign_var_name: hook})
else:
self.__step_context.teardown_hooks.append(hook)
return self
def extract(self) -> StepRequestExtraction: def extract(self) -> StepRequestExtraction:
return StepRequestExtraction(self.__step_context) return StepRequestExtraction(self.__step_context)
@@ -286,6 +293,14 @@ class RunRequest(object):
self.__step_context.variables.update(variables) self.__step_context.variables.update(variables)
return self return self
def setup_hook(self, hook: Text, assign_var_name: Text = None) -> "RunRequest":
if assign_var_name:
self.__step_context.setup_hooks.append({assign_var_name: hook})
else:
self.__step_context.setup_hooks.append(hook)
return self
def get(self, url: Text) -> RequestWithOptionalArgs: def get(self, url: Text) -> RequestWithOptionalArgs:
self.__step_context.request = TRequest(method=MethodEnum.GET, url=url) self.__step_context.request = TRequest(method=MethodEnum.GET, url=url)
return RequestWithOptionalArgs(self.__step_context) return RequestWithOptionalArgs(self.__step_context)

View File

@@ -212,3 +212,14 @@ def ensure_file_path_valid(file_path: Text) -> Text:
new_file_path = os.path.join(os.getcwd(), f"{os.sep.join(path_names)}{file_suffix}") new_file_path = os.path.join(os.getcwd(), f"{os.sep.join(path_names)}{file_suffix}")
return new_file_path return new_file_path
class ExtendJSONEncoder(json.JSONEncoder):
""" especially used to safely dump json data with python object, such as MultipartEncoder
"""
def default(self, obj):
try:
return super(ExtendJSONEncoder, self).default(obj)
except (UnicodeDecodeError, TypeError):
return repr(obj)

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "httprunner" name = "httprunner"
version = "3.0.9" version = "3.0.10"
description = "One-stop solution for HTTP(S) testing." description = "One-stop solution for HTTP(S) testing."
license = "Apache-2.0" license = "Apache-2.0"
readme = "README.md" readme = "README.md"
@@ -44,8 +44,8 @@ requests-toolbelt = {version = "^0.9.1", optional = true}
filetype = {version = "^1.0.7", optional = true} filetype = {version = "^1.0.7", optional = true}
[tool.poetry.extras] [tool.poetry.extras]
allure = ["allure-pytest"] # poetry install -E allure allure = ["allure-pytest"] # pip install "httprunner[allure]", poetry install -E allure
upload = ["requests-toolbelt", "filetype"] # poetry install -E upload upload = ["requests-toolbelt", "filetype"] # pip install "httprunner[upload]", poetry install -E upload
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
coverage = "^4.5.4" coverage = "^4.5.4"

View File

@@ -1,8 +1,10 @@
import decimal
import json
import os import os
import unittest import unittest
from httprunner import loader, utils from httprunner import loader, utils
from httprunner.utils import ensure_file_path_valid from httprunner.utils import ensure_file_path_valid, ExtendJSONEncoder
class TestUtils(unittest.TestCase): class TestUtils(unittest.TestCase):
@@ -116,3 +118,14 @@ class TestUtils(unittest.TestCase):
ensure_file_path_valid("examples/postman_echo/request_methods/"), ensure_file_path_valid("examples/postman_echo/request_methods/"),
os.path.join(os.getcwd(), "examples/postman_echo/request_methods"), os.path.join(os.getcwd(), "examples/postman_echo/request_methods"),
) )
def test_safe_dump_json(self):
class A(object):
pass
data = {"a": A(), "b": decimal.Decimal("1.45")}
with self.assertRaises(TypeError):
json.dumps(data)
json.dumps(data, cls=ExtendJSONEncoder)