mirror of
https://github.com/httprunner/httprunner.git
synced 2026-07-06 23:11:21 +08:00
fix html report for HttpRunner 2.0
This commit is contained in:
@@ -50,7 +50,7 @@ class HttpRunner(object):
|
|||||||
except exceptions.MyBaseFailure as ex:
|
except exceptions.MyBaseFailure as ex:
|
||||||
self.fail(str(ex))
|
self.fail(str(ex))
|
||||||
finally:
|
finally:
|
||||||
self.meta_data = test_runner.get_test_data()
|
self.meta_datas = test_runner.meta_datas
|
||||||
|
|
||||||
if "config" in test_dict:
|
if "config" in test_dict:
|
||||||
# run nested testcase
|
# run nested testcase
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ class HttpSession(requests.Session):
|
|||||||
# record actual request info
|
# record actual request info
|
||||||
self.meta_data["request"]["url"] = (response.history and response.history[0] or response).request.url
|
self.meta_data["request"]["url"] = (response.history and response.history[0] or response).request.url
|
||||||
self.meta_data["request"]["headers"] = dict(response.request.headers)
|
self.meta_data["request"]["headers"] = dict(response.request.headers)
|
||||||
self.meta_data["request"]["body"] = response.request.body
|
|
||||||
|
|
||||||
# log request details in debug mode
|
# log request details in debug mode
|
||||||
log_print("request")
|
log_print("request")
|
||||||
|
|||||||
@@ -91,9 +91,169 @@ def stringify_summary(summary):
|
|||||||
suite_summary["name"] = "test suite {}".format(index)
|
suite_summary["name"] = "test suite {}".format(index)
|
||||||
|
|
||||||
for record in suite_summary.get("records"):
|
for record in suite_summary.get("records"):
|
||||||
meta_data = record['meta_data']
|
meta_datas = record['meta_datas']
|
||||||
stringify_data(meta_data, 'request')
|
__stringify_meta_datas(meta_datas)
|
||||||
stringify_data(meta_data, 'response')
|
meta_datas_expanded = []
|
||||||
|
__expand_meta_datas(meta_datas, meta_datas_expanded)
|
||||||
|
record["meta_datas_expanded"] = meta_datas_expanded
|
||||||
|
record["response_time"] = __get_total_response_time(meta_datas_expanded)
|
||||||
|
|
||||||
|
|
||||||
|
def __stringify_request(request_data):
|
||||||
|
""" stringfy HTTP request data
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_data (dict): HTTP request data in dict.
|
||||||
|
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:5000/api/get-token",
|
||||||
|
"method": "POST",
|
||||||
|
"headers": {
|
||||||
|
"User-Agent": "python-requests/2.20.0",
|
||||||
|
"Accept-Encoding": "gzip, deflate",
|
||||||
|
"Accept": "*/*",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"user_agent": "iOS/10.3",
|
||||||
|
"device_sn": "TESTCASE_CREATE_XXX",
|
||||||
|
"os_platform": "ios",
|
||||||
|
"app_version": "2.8.6",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": "52"
|
||||||
|
},
|
||||||
|
"start_timestamp": 1543299567.6505039,
|
||||||
|
"json": {
|
||||||
|
"sign": "cb9d60acd09080ea66c8e63a1c78c6459ea00168"
|
||||||
|
},
|
||||||
|
"verify": false
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
for key, value in request_data.items():
|
||||||
|
|
||||||
|
if isinstance(value, list):
|
||||||
|
value = json.dumps(value, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
elif isinstance(value, bytes):
|
||||||
|
try:
|
||||||
|
encoding = "utf-8"
|
||||||
|
value = escape(value.decode(encoding))
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif not isinstance(value, (basestring, numeric_types, Iterable)):
|
||||||
|
# class instance, e.g. MultipartEncoder()
|
||||||
|
value = repr(value)
|
||||||
|
|
||||||
|
request_data[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def __stringify_response(response_data):
|
||||||
|
""" stringfy HTTP response data
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response_data (dict):
|
||||||
|
|
||||||
|
{
|
||||||
|
"status_code": 404,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": "30",
|
||||||
|
"Server": "Werkzeug/0.14.1 Python/3.7.0",
|
||||||
|
"Date": "Tue, 27 Nov 2018 06:19:27 GMT"
|
||||||
|
},
|
||||||
|
"content_size": 30,
|
||||||
|
"response_time_ms": 3.63,
|
||||||
|
"elapsed_ms": 2.197,
|
||||||
|
"encoding": "None",
|
||||||
|
"content_type": "application/json",
|
||||||
|
"ok": false,
|
||||||
|
"url": "http://127.0.0.1:5000/api/users/9001",
|
||||||
|
"reason": "NOT FOUND",
|
||||||
|
"cookies": {},
|
||||||
|
"json": {
|
||||||
|
"success": false,
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
for key, value in response_data.items():
|
||||||
|
|
||||||
|
if isinstance(value, list):
|
||||||
|
value = json.dumps(value, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
elif isinstance(value, bytes):
|
||||||
|
try:
|
||||||
|
encoding = response_data.get("encoding")
|
||||||
|
if not encoding or encoding == "None":
|
||||||
|
encoding = "utf-8"
|
||||||
|
|
||||||
|
if key == "content" and "image" in response_data["content_type"]:
|
||||||
|
# display image
|
||||||
|
value = "data:{};base64,{}".format(
|
||||||
|
response_data["content_type"],
|
||||||
|
b64encode(value).decode(encoding)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
value = escape(value.decode(encoding))
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif not isinstance(value, (basestring, numeric_types, Iterable)):
|
||||||
|
# class instance, e.g. MultipartEncoder()
|
||||||
|
value = repr(value)
|
||||||
|
|
||||||
|
response_data[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def __expand_meta_datas(meta_datas, meta_datas_expanded):
|
||||||
|
""" expand meta_datas to one level
|
||||||
|
|
||||||
|
Args:
|
||||||
|
meta_datas (dict/list): maybe in nested format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: expanded list in one level
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> meta_datas = [
|
||||||
|
[
|
||||||
|
dict1,
|
||||||
|
dict2
|
||||||
|
],
|
||||||
|
dict3
|
||||||
|
]
|
||||||
|
>>> meta_datas_expanded = []
|
||||||
|
>>> __expand_meta_datas(meta_datas, meta_datas_expanded)
|
||||||
|
>>> print(meta_datas_expanded)
|
||||||
|
[dict1, dict2, dict3]
|
||||||
|
|
||||||
|
"""
|
||||||
|
if isinstance(meta_datas, dict):
|
||||||
|
meta_datas_expanded.append(meta_datas)
|
||||||
|
elif isinstance(meta_datas, list):
|
||||||
|
for meta_data in meta_datas:
|
||||||
|
__expand_meta_datas(meta_data, meta_datas_expanded)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_total_response_time(meta_datas_expanded):
|
||||||
|
""" caculate total response time of all meta_datas
|
||||||
|
"""
|
||||||
|
response_time = 0
|
||||||
|
for meta_data in meta_datas_expanded:
|
||||||
|
response_time += meta_data["response"]["response_time_ms"]
|
||||||
|
|
||||||
|
return "{:.2f}".format(response_time)
|
||||||
|
|
||||||
|
|
||||||
|
def __stringify_meta_datas(meta_datas):
|
||||||
|
|
||||||
|
if isinstance(meta_datas, list):
|
||||||
|
for _meta_data in meta_datas:
|
||||||
|
__stringify_meta_datas(_meta_data)
|
||||||
|
elif isinstance(meta_datas, dict):
|
||||||
|
__stringify_request(meta_datas["request"])
|
||||||
|
__stringify_response(meta_datas["response"])
|
||||||
|
|
||||||
|
|
||||||
def render_html_report(summary, report_template=None, report_dir=None):
|
def render_html_report(summary, report_template=None, report_dir=None):
|
||||||
@@ -136,46 +296,6 @@ def render_html_report(summary, report_template=None, report_dir=None):
|
|||||||
return report_path
|
return report_path
|
||||||
|
|
||||||
|
|
||||||
def stringify_data(meta_data, request_or_response):
|
|
||||||
"""
|
|
||||||
meta_data = {
|
|
||||||
"request": {},
|
|
||||||
"response": {}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
headers = meta_data[request_or_response]["headers"]
|
|
||||||
request_or_response_dict = meta_data[request_or_response]
|
|
||||||
|
|
||||||
for key, value in request_or_response_dict.items():
|
|
||||||
|
|
||||||
if isinstance(value, list):
|
|
||||||
value = json.dumps(value, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
elif isinstance(value, bytes):
|
|
||||||
try:
|
|
||||||
encoding = meta_data["response"].get("encoding")
|
|
||||||
if not encoding or encoding == "None":
|
|
||||||
encoding = "utf-8"
|
|
||||||
|
|
||||||
if request_or_response == "response" and key == "content" \
|
|
||||||
and "image" in meta_data["response"]["content_type"]:
|
|
||||||
# display image
|
|
||||||
value = "data:{};base64,{}".format(
|
|
||||||
meta_data["response"]["content_type"],
|
|
||||||
b64encode(value).decode(encoding)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
value = escape(value.decode(encoding))
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
elif not isinstance(value, (basestring, numeric_types, Iterable)):
|
|
||||||
# class instance, e.g. MultipartEncoder()
|
|
||||||
value = repr(value)
|
|
||||||
|
|
||||||
meta_data[request_or_response][key] = value
|
|
||||||
|
|
||||||
|
|
||||||
class HtmlTestResult(unittest.TextTestResult):
|
class HtmlTestResult(unittest.TextTestResult):
|
||||||
""" A html result class that can generate formatted html results.
|
""" A html result class that can generate formatted html results.
|
||||||
Used by TextTestRunner.
|
Used by TextTestRunner.
|
||||||
@@ -189,7 +309,7 @@ class HtmlTestResult(unittest.TextTestResult):
|
|||||||
'name': test.shortDescription(),
|
'name': test.shortDescription(),
|
||||||
'status': status,
|
'status': status,
|
||||||
'attachment': attachment,
|
'attachment': attachment,
|
||||||
"meta_data": test.meta_data
|
"meta_datas": test.meta_datas
|
||||||
}
|
}
|
||||||
self.records.append(data)
|
self.records.append(data)
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class Runner(object):
|
|||||||
if self.testcase_teardown_hooks:
|
if self.testcase_teardown_hooks:
|
||||||
self.do_hook_actions(self.testcase_teardown_hooks, "teardown")
|
self.do_hook_actions(self.testcase_teardown_hooks, "teardown")
|
||||||
|
|
||||||
def clear_test_data(self):
|
def __clear_test_data(self):
|
||||||
""" clear request and response data
|
""" clear request and response data
|
||||||
"""
|
"""
|
||||||
if not isinstance(self.http_client_session, HttpSession):
|
if not isinstance(self.http_client_session, HttpSession):
|
||||||
@@ -77,11 +77,11 @@ class Runner(object):
|
|||||||
self.evaluated_validators = []
|
self.evaluated_validators = []
|
||||||
self.http_client_session.init_meta_data()
|
self.http_client_session.init_meta_data()
|
||||||
|
|
||||||
def get_test_data(self):
|
def __get_test_data(self):
|
||||||
""" get request/response data and validate results
|
""" get request/response data and validate results
|
||||||
"""
|
"""
|
||||||
if not isinstance(self.http_client_session, HttpSession):
|
if not isinstance(self.http_client_session, HttpSession):
|
||||||
raise exceptions.FunctionNotFound("get_test_data is only valid in HttpSession!")
|
return
|
||||||
|
|
||||||
meta_data = self.http_client_session.meta_data
|
meta_data = self.http_client_session.meta_data
|
||||||
meta_data["validators"] = self.evaluated_validators
|
meta_data["validators"] = self.evaluated_validators
|
||||||
@@ -167,7 +167,7 @@ class Runner(object):
|
|||||||
"authorization": "$authorization",
|
"authorization": "$authorization",
|
||||||
"random": "$random"
|
"random": "$random"
|
||||||
},
|
},
|
||||||
"body": '{"name": "user", "password": "123456"}'
|
"json": {"name": "user", "password": "123456"}
|
||||||
},
|
},
|
||||||
"extract": [], # optional
|
"extract": [], # optional
|
||||||
"validate": [], # optional
|
"validate": [], # optional
|
||||||
@@ -182,7 +182,7 @@ class Runner(object):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
# clear meta data first to ensure independence for each test
|
# clear meta data first to ensure independence for each test
|
||||||
self.clear_test_data()
|
self.__clear_test_data()
|
||||||
|
|
||||||
# check skip
|
# check skip
|
||||||
self._handle_skip_feature(test_dict)
|
self._handle_skip_feature(test_dict)
|
||||||
@@ -266,14 +266,18 @@ class Runner(object):
|
|||||||
def _run_testcase(self, testcase_dict):
|
def _run_testcase(self, testcase_dict):
|
||||||
""" run single testcase.
|
""" run single testcase.
|
||||||
"""
|
"""
|
||||||
|
meta_data_list = []
|
||||||
config = testcase_dict.get("config", {})
|
config = testcase_dict.get("config", {})
|
||||||
test_runner = Runner(config, self.functions, self.http_client_session)
|
test_runner = Runner(config, self.functions, self.http_client_session)
|
||||||
|
|
||||||
tests = testcase_dict.get("tests", [])
|
tests = testcase_dict.get("tests", [])
|
||||||
for index, test_dict in enumerate(tests):
|
for index, test_dict in enumerate(tests):
|
||||||
test_runner.run_test(test_dict)
|
test_runner.run_test(test_dict)
|
||||||
|
meta_datas = test_runner.meta_datas
|
||||||
|
meta_data_list.append(meta_datas)
|
||||||
|
|
||||||
self.session_context.update_seesion_variables(test_runner.extract_sessions())
|
self.session_context.update_seesion_variables(test_runner.extract_sessions())
|
||||||
|
return meta_data_list
|
||||||
|
|
||||||
def run_test(self, test_dict):
|
def run_test(self, test_dict):
|
||||||
""" run single teststep of testcase.
|
""" run single teststep of testcase.
|
||||||
@@ -308,12 +312,14 @@ class Runner(object):
|
|||||||
}
|
}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
self.meta_datas = None
|
||||||
if "config" in test_dict:
|
if "config" in test_dict:
|
||||||
# nested testcase
|
# nested testcase
|
||||||
self._run_testcase(test_dict)
|
self.meta_datas = self._run_testcase(test_dict)
|
||||||
else:
|
else:
|
||||||
# api
|
# api
|
||||||
self._run_test(test_dict)
|
self._run_test(test_dict)
|
||||||
|
self.meta_datas = self.__get_test_data()
|
||||||
|
|
||||||
def extract_sessions(self):
|
def extract_sessions(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -233,28 +233,31 @@
|
|||||||
|
|
||||||
{% for record in test_suite_summary.records %}
|
{% for record in test_suite_summary.records %}
|
||||||
{% set record_index = "{}_{}".format(suite_index, loop.index) %}
|
{% set record_index = "{}_{}".format(suite_index, loop.index) %}
|
||||||
|
{% set record_meta_datas = record.meta_datas_expanded %}
|
||||||
<tr id="record_{{record_index}}">
|
<tr id="record_{{record_index}}">
|
||||||
<th class="{{record.status}}" style="width:5em;">{{record.status}}</td>
|
<th class="{{record.status}}" style="width:5em;">{{record.status}}</td>
|
||||||
<td colspan="2">{{record.name}}</td>
|
<td colspan="2">{{record.name}}</td>
|
||||||
<td style="text-align:center;width:6em;">{{ record.meta_data.response.response_time_ms }} ms</td>
|
<td style="text-align:center;width:6em;">{{ record.response_time }} ms</td>
|
||||||
<td class="detail">
|
<td class="detail">
|
||||||
|
|
||||||
<a class="button" href="#popup_log_{{record_index}}">log</a>
|
{% for meta_data in record_meta_datas %}
|
||||||
<div id="popup_log_{{record_index}}" class="overlay">
|
{% set meta_data_index = "{}_{}".format(record_index, loop.index) %}
|
||||||
|
<a class="button" href="#popup_log_{{meta_data_index}}">log-{{loop.index}}</a>
|
||||||
|
<div id="popup_log_{{meta_data_index}}" class="overlay">
|
||||||
<div class="popup">
|
<div class="popup">
|
||||||
<h2>Request and Response data</h2>
|
<h2>Request and Response data</h2>
|
||||||
<a class="close" href="#record_{{record_index}}">×</a>
|
<a class="close" href="#record_{{meta_data_index}}">×</a>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h3>Request:</h3>
|
<h3>Request:</h3>
|
||||||
<div style="overflow: auto">
|
<div style="overflow: auto">
|
||||||
<table>
|
<table>
|
||||||
{% for key, value in record.meta_data.request.items() %}
|
{% for key, value in meta_data.request.items() %}
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{key}}</th>
|
<th>{{key}}</th>
|
||||||
<td>
|
<td>
|
||||||
{% if key == "headers" %}
|
{% if key == "headers" %}
|
||||||
{% for header_key, header_value in record.meta_data.request.headers.items() %}
|
{% for header_key, header_value in meta_data.request.headers.items() %}
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ header_key }}</strong>: {{ header_value }}
|
<strong>{{ header_key }}</strong>: {{ header_value }}
|
||||||
</div>
|
</div>
|
||||||
@@ -271,7 +274,7 @@
|
|||||||
<h3>Response:</h3>
|
<h3>Response:</h3>
|
||||||
<div style="overflow: auto">
|
<div style="overflow: auto">
|
||||||
<table>
|
<table>
|
||||||
{% for key, value in record.meta_data.response.items() %}
|
{% for key, value in meta_data.response.items() %}
|
||||||
{% if key in ["elapsed_ms", "response_time_ms", "content_size", "content_type"] %}
|
{% if key in ["elapsed_ms", "response_time_ms", "content_size", "content_type"] %}
|
||||||
{% continue %}
|
{% continue %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -279,19 +282,19 @@
|
|||||||
<th>{{key}}</th>
|
<th>{{key}}</th>
|
||||||
<td>
|
<td>
|
||||||
{% if key == "headers" %}
|
{% if key == "headers" %}
|
||||||
{% for header_key, header_value in record.meta_data.response.headers.items() %}
|
{% for header_key, header_value in meta_data.response.headers.items() %}
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ header_key }}</strong>: {{ header_value }}
|
<strong>{{ header_key }}</strong>: {{ header_value }}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% elif key == "content" %}
|
{% elif key == "content" %}
|
||||||
{% if "image" in record.meta_data.response.content_type %}
|
{% if "image" in meta_data.response.content_type %}
|
||||||
<img src="{{ record.meta_data.response.content }}" />
|
<img src="{{ meta_data.response.content }}" />
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ value }}
|
{{ value }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif key == "text" %}
|
{% elif key == "text" %}
|
||||||
<pre>{{ record.meta_data.response.text | e }}</pre>
|
<pre>{{ meta_data.response.text | e }}</pre>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ value }}
|
{{ value }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -310,7 +313,7 @@
|
|||||||
<th>expect value</th>
|
<th>expect value</th>
|
||||||
<th>actual value</th>
|
<th>actual value</th>
|
||||||
</tr>
|
</tr>
|
||||||
{% for validator in record.meta_data.validators %}
|
{% for validator in meta_data.validators %}
|
||||||
<tr>
|
<tr>
|
||||||
{% if validator.check_result == "pass" %}
|
{% if validator.check_result == "pass" %}
|
||||||
<td class="passed">
|
<td class="passed">
|
||||||
@@ -334,15 +337,15 @@
|
|||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>content_size(bytes)</th>
|
<th>content_size(bytes)</th>
|
||||||
<td>{{ record.meta_data.response.content_size }}</td>
|
<td>{{ meta_data.response.content_size }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>response_time(ms)</th>
|
<th>response_time(ms)</th>
|
||||||
<td>{{ record.meta_data.response.response_time_ms }}</td>
|
<td>{{ meta_data.response.response_time_ms }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>elapsed(ms)</th>
|
<th>elapsed(ms)</th>
|
||||||
<td>{{ record.meta_data.response.elapsed_ms }}</td>
|
<td>{{ meta_data.response.elapsed_ms }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,6 +353,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
{% if record.attachment %}
|
{% if record.attachment %}
|
||||||
<a class="button" href="#popup_attachment_{{record_index}}">traceback</a>
|
<a class="button" href="#popup_attachment_{{record_index}}">traceback</a>
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ class TestHttpRunner(ApiServerUnittest):
|
|||||||
summary = self.runner.summary
|
summary = self.runner.summary
|
||||||
self.assertTrue(summary["success"])
|
self.assertTrue(summary["success"])
|
||||||
self.assertEqual(summary["stat"]["testsRun"], 1)
|
self.assertEqual(summary["stat"]["testsRun"], 1)
|
||||||
self.assertEqual(summary["details"][0]["records"][0]["meta_data"]["response"]["json"]["data"], "abc")
|
self.assertEqual(summary["details"][0]["records"][0]["meta_datas"]["response"]["json"]["data"], "abc")
|
||||||
|
|
||||||
def test_html_report_repsonse_image(self):
|
def test_html_report_repsonse_image(self):
|
||||||
report_save_dir = os.path.join(os.getcwd(), 'reports', "demo")
|
report_save_dir = os.path.join(os.getcwd(), 'reports', "demo")
|
||||||
|
|||||||
Reference in New Issue
Block a user