Kea2-python 1.1.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kea2/__init__.py +8 -0
- kea2/absDriver.py +56 -0
- kea2/adbUtils.py +554 -0
- kea2/assets/config_version.json +16 -0
- kea2/assets/fastbot-thirdpart.jar +0 -0
- kea2/assets/fastbot_configs/abl.strings +2 -0
- kea2/assets/fastbot_configs/awl.strings +3 -0
- kea2/assets/fastbot_configs/max.config +7 -0
- kea2/assets/fastbot_configs/max.fuzzing.strings +699 -0
- kea2/assets/fastbot_configs/max.schema.strings +1 -0
- kea2/assets/fastbot_configs/max.strings +3 -0
- kea2/assets/fastbot_configs/max.tree.pruning +27 -0
- kea2/assets/fastbot_configs/teardown.py +18 -0
- kea2/assets/fastbot_configs/widget.block.py +38 -0
- kea2/assets/fastbot_libs/arm64-v8a/libfastbot_native.so +0 -0
- kea2/assets/fastbot_libs/armeabi-v7a/libfastbot_native.so +0 -0
- kea2/assets/fastbot_libs/x86/libfastbot_native.so +0 -0
- kea2/assets/fastbot_libs/x86_64/libfastbot_native.so +0 -0
- kea2/assets/framework.jar +0 -0
- kea2/assets/kea2-thirdpart.jar +0 -0
- kea2/assets/monkeyq.jar +0 -0
- kea2/assets/quicktest.py +126 -0
- kea2/cli.py +216 -0
- kea2/fastbotManager.py +269 -0
- kea2/kea2_api.py +166 -0
- kea2/keaUtils.py +926 -0
- kea2/kea_launcher.py +299 -0
- kea2/logWatcher.py +92 -0
- kea2/mixin.py +0 -0
- kea2/report/__init__.py +0 -0
- kea2/report/bug_report_generator.py +879 -0
- kea2/report/mixin.py +496 -0
- kea2/report/report_merger.py +1066 -0
- kea2/report/templates/bug_report_template.html +4028 -0
- kea2/report/templates/merged_bug_report_template.html +3602 -0
- kea2/report/utils.py +10 -0
- kea2/result.py +257 -0
- kea2/resultSyncer.py +65 -0
- kea2/state.py +22 -0
- kea2/typedefs.py +32 -0
- kea2/u2Driver.py +612 -0
- kea2/utils.py +192 -0
- kea2/version_manager.py +102 -0
- kea2_python-1.1.0b1.dist-info/METADATA +447 -0
- kea2_python-1.1.0b1.dist-info/RECORD +49 -0
- kea2_python-1.1.0b1.dist-info/WHEEL +5 -0
- kea2_python-1.1.0b1.dist-info/entry_points.txt +2 -0
- kea2_python-1.1.0b1.dist-info/licenses/LICENSE +16 -0
- kea2_python-1.1.0b1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
snssdk1128://webcast_feed?gd_label=88888
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"activity":"com.ss.android.xxx.NewActivity",
|
|
4
|
+
"xpath": "//*[@resource-id='com.xxx.go:id/aaa']",
|
|
5
|
+
"resourceid": "",
|
|
6
|
+
"contentdesc":"",
|
|
7
|
+
"text":"",
|
|
8
|
+
"classname":""
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"activity":"com.ss.android.xxx.MainActivity",
|
|
12
|
+
"xpath": "//*[@resource-id='com.xxx.go:id/bbb' and @text='other']",
|
|
13
|
+
"resourceid": "",
|
|
14
|
+
"contentdesc":"",
|
|
15
|
+
"text":"",
|
|
16
|
+
"classname":""
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"activity":"com.ss.android.xxx.SplashActivity",
|
|
20
|
+
"xpath": "//*[@resource-id='com.com.xxx.go:id/ccc' and @text='other']",
|
|
21
|
+
"resourceid": "",
|
|
22
|
+
"contentdesc":"",
|
|
23
|
+
"text":"",
|
|
24
|
+
"classname":"",
|
|
25
|
+
"clickable":"false"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from uiautomator2 import Device
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class HybridTestCase:
|
|
6
|
+
d: Device
|
|
7
|
+
|
|
8
|
+
PACKAGE_NAME = "it.feio.android.omninotes.alpha"
|
|
9
|
+
MAIN_ACTIVITY = "it.feio.android.omninotes.MainActivity"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def setUp(self: HybridTestCase):
|
|
13
|
+
self.d.app_start(PACKAGE_NAME, MAIN_ACTIVITY)
|
|
14
|
+
time.sleep(2)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def tearDown(self: HybridTestCase):
|
|
18
|
+
self.d.app_stop(PACKAGE_NAME)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from kea2.utils import Device
|
|
2
|
+
from kea2.keaUtils import precondition
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def global_block_widgets(d: "Device"):
|
|
6
|
+
"""
|
|
7
|
+
Specify UI widgets to be blocked globally during testing.
|
|
8
|
+
Returns a list of widgets that should be blocked from exploration.
|
|
9
|
+
This function is only available in 'u2 agent' mode.
|
|
10
|
+
"""
|
|
11
|
+
# return [d(text="widgets to block"), d.xpath(".//node[@text='widget to block']")]
|
|
12
|
+
return []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Example of conditional blocking with precondition
|
|
16
|
+
# @precondition(lambda d: d(text="In the home page").exists)
|
|
17
|
+
@precondition(lambda d: False)
|
|
18
|
+
def block_sth(d: "Device"):
|
|
19
|
+
# Note: Function name must start with "block_"
|
|
20
|
+
return []
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def global_block_tree(d: "Device"):
|
|
24
|
+
"""
|
|
25
|
+
Specify UI widget trees to be blocked globally during testing.
|
|
26
|
+
Returns a list of root nodes whose entire subtrees will be blocked from exploration.
|
|
27
|
+
This function is only available in 'u2 agent' mode.
|
|
28
|
+
"""
|
|
29
|
+
# return [d(text="trees to block"), d.xpath(".//node[@text='tree to block']")]
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Example of conditional tree blocking with precondition
|
|
34
|
+
# @precondition(lambda d: d(text="In the home page").exists)
|
|
35
|
+
@precondition(lambda d: False)
|
|
36
|
+
def block_tree_sth(d: "Device"):
|
|
37
|
+
# Note: Function name must start with "block_tree_"
|
|
38
|
+
return []
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
kea2/assets/monkeyq.jar
ADDED
|
Binary file
|
kea2/assets/quicktest.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
import uiautomator2 as u2
|
|
3
|
+
|
|
4
|
+
from time import sleep
|
|
5
|
+
from kea2 import precondition, prob, KeaTestRunner, Options, kea, keaTestLoader
|
|
6
|
+
from kea2.u2Driver import U2Driver
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Omni_Notes_Sample(unittest.TestCase):
|
|
10
|
+
|
|
11
|
+
def setUp(self):
|
|
12
|
+
self.d = u2.connect()
|
|
13
|
+
|
|
14
|
+
@prob(0.2)
|
|
15
|
+
@precondition(
|
|
16
|
+
lambda self: self.d(description="Navigate up").exists
|
|
17
|
+
)
|
|
18
|
+
def test_goBack(self):
|
|
19
|
+
print("Navigate back")
|
|
20
|
+
self.d(description="Navigate up").click()
|
|
21
|
+
sleep(0.5)
|
|
22
|
+
|
|
23
|
+
@prob(0.2)
|
|
24
|
+
@precondition(
|
|
25
|
+
lambda self: self.d(description="drawer closed").exists
|
|
26
|
+
)
|
|
27
|
+
def test_openDrawer(self):
|
|
28
|
+
print("Open drawer")
|
|
29
|
+
self.d(description="drawer closed").click()
|
|
30
|
+
sleep(0.5)
|
|
31
|
+
|
|
32
|
+
@prob(0.5) # The probability of executing the function when precondition is satisfied.
|
|
33
|
+
@precondition(
|
|
34
|
+
lambda self: self.d(text="Omni Notes Alpha").exists
|
|
35
|
+
and self.d(text="Settings").exists
|
|
36
|
+
)
|
|
37
|
+
def test_goToPrivacy(self):
|
|
38
|
+
"""
|
|
39
|
+
The ability to jump out of the UI tarpits
|
|
40
|
+
|
|
41
|
+
precond:
|
|
42
|
+
The drawer was opened
|
|
43
|
+
action:
|
|
44
|
+
go to settings -> privacy
|
|
45
|
+
"""
|
|
46
|
+
print("trying to click Settings")
|
|
47
|
+
self.d(text="Settings").click()
|
|
48
|
+
sleep(0.5)
|
|
49
|
+
print("trying to click Privacy")
|
|
50
|
+
self.d(text="Privacy").click()
|
|
51
|
+
|
|
52
|
+
@precondition(
|
|
53
|
+
lambda self: self.d(resourceId="it.feio.android.omninotes.alpha:id/search_src_text").exists
|
|
54
|
+
)
|
|
55
|
+
def test_rotation(self):
|
|
56
|
+
"""
|
|
57
|
+
The ability to make assertion to find functional bug
|
|
58
|
+
|
|
59
|
+
precond:
|
|
60
|
+
The search input box is opened
|
|
61
|
+
action:
|
|
62
|
+
rotate the device (set it to landscape, then back to natural)
|
|
63
|
+
assertion:
|
|
64
|
+
The search input box is still being opened
|
|
65
|
+
"""
|
|
66
|
+
print("rotate the device")
|
|
67
|
+
self.d.set_orientation("l")
|
|
68
|
+
sleep(2)
|
|
69
|
+
self.d.set_orientation("n")
|
|
70
|
+
sleep(2)
|
|
71
|
+
assert self.d(resourceId="it.feio.android.omninotes.alpha:id/search_src_text").exists()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
URL = "https://github.com/federicoiosue/Omni-Notes/releases/download/6.2.0_alpha/OmniNotes-alphaRelease-6.2.0.apk"
|
|
75
|
+
FALL_BACK_URL = "https://gitee.com/XixianLiang/Kea2/raw/main/omninotes.apk"
|
|
76
|
+
PACKAGE_NAME = "it.feio.android.omninotes.alpha"
|
|
77
|
+
FILE_NAME = "omninotes.apk"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def download_omninotes():
|
|
81
|
+
import socket
|
|
82
|
+
socket.setdefaulttimeout(30)
|
|
83
|
+
try:
|
|
84
|
+
import urllib.request
|
|
85
|
+
urllib.request.urlretrieve(URL, FILE_NAME)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
print(f"[WARN] Download from {URL} failed: {e}. Try to download from fallback URL {FALL_BACK_URL}", flush=True)
|
|
88
|
+
try:
|
|
89
|
+
urllib.request.urlretrieve(FALL_BACK_URL, FILE_NAME)
|
|
90
|
+
except Exception as e2:
|
|
91
|
+
print(f"[ERROR] Download from fallback URL {FALL_BACK_URL} also failed: {e2}", flush=True)
|
|
92
|
+
raise e2
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check_installation(serial=None):
|
|
96
|
+
import os
|
|
97
|
+
from pathlib import Path
|
|
98
|
+
|
|
99
|
+
d = u2.connect(serial)
|
|
100
|
+
# automatically install omni-notes
|
|
101
|
+
if PACKAGE_NAME not in d.app_list():
|
|
102
|
+
if not os.path.exists(Path(".") / FILE_NAME):
|
|
103
|
+
print(f"[INFO] omninote.apk not exists. Downloading from {URL}", flush=True)
|
|
104
|
+
download_omninotes()
|
|
105
|
+
print("[INFO] Installing omninotes.", flush=True)
|
|
106
|
+
d.app_install(FILE_NAME)
|
|
107
|
+
d.stop_uiautomator()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
check_installation(serial=None)
|
|
112
|
+
KeaTestRunner.setOptions(
|
|
113
|
+
Options(
|
|
114
|
+
driverName="d",
|
|
115
|
+
Driver=U2Driver,
|
|
116
|
+
packageNames=[PACKAGE_NAME],
|
|
117
|
+
# serial="emulator-5554", # specify the serial
|
|
118
|
+
maxStep=50,
|
|
119
|
+
profile_period=10,
|
|
120
|
+
take_screenshots=True, # whether to take screenshots, default is False
|
|
121
|
+
# running_mins=10, # specify the maximal running time in minutes, default value is 10m
|
|
122
|
+
# throttle=200, # specify the throttle in milliseconds, default value is 200ms
|
|
123
|
+
agent="u2" # 'native' for running the vanilla Fastbot, 'u2' for running Kea2
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
unittest.main(testRunner=KeaTestRunner, testLoader=keaTestLoader)
|
kea2/cli.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# coding: utf-8
|
|
2
|
+
# cli.py
|
|
3
|
+
|
|
4
|
+
from __future__ import absolute_import, print_function
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
import sys
|
|
7
|
+
from .utils import getProjectRoot, getLogger
|
|
8
|
+
from .kea_launcher import run
|
|
9
|
+
from .version_manager import check_config_compatibility, get_cur_version
|
|
10
|
+
import argparse
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
logger = getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cmd_version(args):
|
|
20
|
+
print(get_cur_version(), flush=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cmd_init(args):
|
|
24
|
+
cwd = Path(os.getcwd())
|
|
25
|
+
configs_dir = cwd / "configs"
|
|
26
|
+
if os.path.isdir(configs_dir):
|
|
27
|
+
logger.warning("Kea2 project already initialized")
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
import shutil
|
|
31
|
+
def copy_configs():
|
|
32
|
+
src = Path(__file__).parent / "assets" / "fastbot_configs"
|
|
33
|
+
dst = configs_dir
|
|
34
|
+
shutil.copytree(src, dst)
|
|
35
|
+
|
|
36
|
+
def copy_samples():
|
|
37
|
+
src = Path(__file__).parent / "assets" / "quicktest.py"
|
|
38
|
+
dst = cwd / "quicktest.py"
|
|
39
|
+
shutil.copyfile(src, dst)
|
|
40
|
+
|
|
41
|
+
def save_version():
|
|
42
|
+
import json
|
|
43
|
+
version_file = configs_dir / "version.json"
|
|
44
|
+
with open(version_file, "w") as fp:
|
|
45
|
+
json.dump({"version": get_cur_version(), "init date": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}, fp, indent=4)
|
|
46
|
+
|
|
47
|
+
copy_configs()
|
|
48
|
+
copy_samples()
|
|
49
|
+
save_version()
|
|
50
|
+
logger.info("Kea2 project initialized.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_load_configs(args):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cmd_report(args):
|
|
58
|
+
from .report.bug_report_generator import BugReportGenerator
|
|
59
|
+
report_dirs = args.path
|
|
60
|
+
|
|
61
|
+
for report_dir in report_dirs:
|
|
62
|
+
report_dir = Path(report_dir).resolve()
|
|
63
|
+
|
|
64
|
+
if not report_dir.exists():
|
|
65
|
+
logger.error(f"Report directory does not exist: {str(report_dir)}, Skipped.")
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
logger.debug(f"Generating test report from directory: {report_dir}")
|
|
69
|
+
BugReportGenerator(report_dir).generate_report()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_merge(args):
|
|
73
|
+
"""Merge multiple test report directories and generate a combined report"""
|
|
74
|
+
from .report.report_merger import TestReportMerger
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
# Validate input paths
|
|
78
|
+
if not args.paths or len(args.paths) < 2:
|
|
79
|
+
logger.error("At least 2 test report paths are required for merging. Use -p to specify paths.")
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
# Validate that all paths exist
|
|
83
|
+
for path in args.paths:
|
|
84
|
+
path_obj = Path(path)
|
|
85
|
+
if not path_obj.exists():
|
|
86
|
+
raise FileNotFoundError(f"{path_obj}")
|
|
87
|
+
if not path_obj.is_dir():
|
|
88
|
+
raise NotADirectoryError(f"{path_obj}")
|
|
89
|
+
|
|
90
|
+
logger.debug(f"Merging {len(args.paths)} test report directories...")
|
|
91
|
+
|
|
92
|
+
# Initialize merger
|
|
93
|
+
merger = TestReportMerger()
|
|
94
|
+
|
|
95
|
+
# Merge test reports
|
|
96
|
+
merged_report = merger.merge_reports(args.paths, args.output)
|
|
97
|
+
|
|
98
|
+
if merged_report is not None:
|
|
99
|
+
print(f"✅ Test reports merged successfully!", flush=True)
|
|
100
|
+
print(f"📊 Merged report: {merged_report}", flush=True)
|
|
101
|
+
# Get merge summary
|
|
102
|
+
merge_summary = merger.get_merge_summary()
|
|
103
|
+
print(f"📈 Merged {merge_summary.get('merged_directories', 0)} directories", flush=True)
|
|
104
|
+
|
|
105
|
+
except Exception as e:
|
|
106
|
+
logger.error(f"Error during merge operation: {e}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cmd_run(args):
|
|
110
|
+
base_dir = getProjectRoot()
|
|
111
|
+
if base_dir is None:
|
|
112
|
+
logger.error("kea2 project not initialized. Use `kea2 init`.")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
check_config_compatibility()
|
|
116
|
+
|
|
117
|
+
run(args)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_commands = [
|
|
121
|
+
dict(action=cmd_version, command="version", help="show version"),
|
|
122
|
+
dict(
|
|
123
|
+
action=cmd_init,
|
|
124
|
+
command="init",
|
|
125
|
+
help="init the Kea2 project in current directory",
|
|
126
|
+
),
|
|
127
|
+
dict(
|
|
128
|
+
action=cmd_report,
|
|
129
|
+
command="report",
|
|
130
|
+
help="generate test report from existing test results",
|
|
131
|
+
flags=[
|
|
132
|
+
dict(
|
|
133
|
+
name=["report_dir"],
|
|
134
|
+
args=["-p", "--path"],
|
|
135
|
+
type=str,
|
|
136
|
+
nargs="+",
|
|
137
|
+
required=True,
|
|
138
|
+
help="Root directory path of the test results to generate report from"
|
|
139
|
+
)
|
|
140
|
+
]
|
|
141
|
+
),
|
|
142
|
+
dict(
|
|
143
|
+
action=cmd_merge,
|
|
144
|
+
command="merge",
|
|
145
|
+
help="merge multiple test report directories and generate a combined report",
|
|
146
|
+
flags=[
|
|
147
|
+
dict(
|
|
148
|
+
name=["paths"],
|
|
149
|
+
args=["-p", "--paths"],
|
|
150
|
+
type=str,
|
|
151
|
+
nargs='+',
|
|
152
|
+
required=True,
|
|
153
|
+
help="Paths to test report directories (res_* directories) to merge"
|
|
154
|
+
),
|
|
155
|
+
dict(
|
|
156
|
+
name=["output"],
|
|
157
|
+
args=["-o", "--output"],
|
|
158
|
+
type=str,
|
|
159
|
+
required=False,
|
|
160
|
+
help="Output directory for merged report (optional)"
|
|
161
|
+
)
|
|
162
|
+
]
|
|
163
|
+
)
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def main():
|
|
168
|
+
parser = argparse.ArgumentParser(
|
|
169
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
|
170
|
+
)
|
|
171
|
+
parser.add_argument("-d", "--debug", action="store_true",
|
|
172
|
+
help="show detail log")
|
|
173
|
+
|
|
174
|
+
subparser = parser.add_subparsers(dest='subparser')
|
|
175
|
+
|
|
176
|
+
actions = {}
|
|
177
|
+
for c in _commands:
|
|
178
|
+
cmd_name = c['command']
|
|
179
|
+
actions[cmd_name] = c['action']
|
|
180
|
+
sp = subparser.add_parser(
|
|
181
|
+
cmd_name,
|
|
182
|
+
help=c.get('help'),
|
|
183
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
|
184
|
+
)
|
|
185
|
+
for f in c.get('flags', []):
|
|
186
|
+
args = f.get('args')
|
|
187
|
+
if not args:
|
|
188
|
+
args = ['-'*min(2, len(n)) + n for n in f['name']]
|
|
189
|
+
kwargs = f.copy()
|
|
190
|
+
kwargs.pop('name', None)
|
|
191
|
+
kwargs.pop('args', None)
|
|
192
|
+
sp.add_argument(*args, **kwargs)
|
|
193
|
+
|
|
194
|
+
from .kea_launcher import _set_runner_parser
|
|
195
|
+
_set_runner_parser(subparser)
|
|
196
|
+
actions["run"] = cmd_run
|
|
197
|
+
if sys.argv[1:] == ["run"]:
|
|
198
|
+
sys.argv.append("-h")
|
|
199
|
+
args = parser.parse_args()
|
|
200
|
+
|
|
201
|
+
import logging
|
|
202
|
+
from .utils import LoggingLevel
|
|
203
|
+
LoggingLevel.set_level(logging.INFO)
|
|
204
|
+
if args.debug:
|
|
205
|
+
LoggingLevel.set_level(logging.DEBUG)
|
|
206
|
+
logger.debug("args: %s", args)
|
|
207
|
+
|
|
208
|
+
if args.subparser:
|
|
209
|
+
actions[args.subparser](args)
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
parser.print_help()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
main()
|