Kea2-python 0.0.1a0__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.

Potentially problematic release.


This version of Kea2-python might be problematic. Click here for more details.

kea2/keaUtils.py ADDED
@@ -0,0 +1,535 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ import subprocess
5
+ import threading
6
+ from typing import IO, Callable, Any, Dict, List, Literal, NewType, Optional, Union
7
+ from unittest import TextTestRunner, registerResult, TestSuite, TestCase, TextTestResult
8
+ import random
9
+ import warnings
10
+ from dataclasses import dataclass, asdict
11
+ import requests
12
+ from .absDriver import AbstractDriver
13
+ from functools import wraps
14
+ from time import sleep
15
+ from .adbUtils import push_file
16
+ from .logWatcher import LogWatcher
17
+ from .utils import TimeStamp, getProjectRoot, getLogger
18
+ import types
19
+ PRECONDITIONS_MARKER = "preconds"
20
+ PROP_MARKER = "prop"
21
+
22
+
23
+ logger = getLogger(__name__)
24
+
25
+
26
+ # Class Typing
27
+ PropName = NewType("PropName", str)
28
+ PropertyStore = NewType("PropertyStore", Dict[PropName, TestCase])
29
+
30
+ TIME_STAMP = TimeStamp().getTimeStamp()
31
+ LOGFILE = f"fastbot_{TIME_STAMP}.log"
32
+ RESFILE = f"result_{TIME_STAMP}.json"
33
+
34
+ def precondition(precond: Callable[[Any], bool]) -> Callable:
35
+ """the decorator @precondition
36
+
37
+ The precondition specifies when the property could be executed.
38
+ A property could have multiple preconditions, each of which is specified by @precondition.
39
+ """
40
+ def accept(f):
41
+ @wraps(f)
42
+ def precondition_wrapper(*args, **kwargs):
43
+ return f(*args, **kwargs)
44
+
45
+ preconds = getattr(f, PRECONDITIONS_MARKER, tuple())
46
+
47
+ setattr(precondition_wrapper, PRECONDITIONS_MARKER, preconds + (precond,))
48
+
49
+ return precondition_wrapper
50
+
51
+ return accept
52
+
53
+ def prob(p: float):
54
+ """the decorator @prob
55
+
56
+ The prob specify the propbability of execution when a property is satisfied.
57
+ """
58
+ p = float(p)
59
+ if not 0 < p <= 1.0:
60
+ raise ValueError("The propbability should between 0 and 1")
61
+ def accept(f):
62
+ @wraps(f)
63
+ def precondition_wrapper(*args, **kwargs):
64
+ return f(*args, **kwargs)
65
+
66
+ setattr(precondition_wrapper, PROP_MARKER, p)
67
+
68
+ return precondition_wrapper
69
+
70
+ return accept
71
+
72
+
73
+ @dataclass
74
+ class Options:
75
+ """
76
+ Kea and Fastbot configurations
77
+ """
78
+ # the driver_name in script (if self.d, then d.)
79
+ driverName: str
80
+ # the driver (only U2Driver available now)
81
+ Driver: AbstractDriver
82
+ # list of package names. Specify the apps under test
83
+ packageNames: List[str]
84
+ # target device
85
+ serial: str = None
86
+ # test agent. "native" for stage 1 and "u2" for stage 1~3
87
+ agent: Literal["u2", "native"] = "u2"
88
+ # max step in exploration (availble in stage 2~3)
89
+ maxStep: Union[str, float] = float("inf")
90
+ # time(mins) for exploration
91
+ running_mins: int = 10
92
+ # time(ms) to wait when exploring the app
93
+ throttle: int = 200
94
+
95
+
96
+ @dataclass
97
+ class PropStatistic:
98
+ precond_satisfied: int = 0
99
+ executed: int = 0
100
+ fail: int = 0
101
+ error: int = 0
102
+
103
+ class PBTTestResult(dict):
104
+ def __getitem__(self, key) -> PropStatistic:
105
+ return super().__getitem__(key)
106
+
107
+
108
+ def getFullPropName(testCase: TestCase):
109
+ return ".".join([
110
+ testCase.__module__,
111
+ testCase.__class__.__name__,
112
+ testCase._testMethodName
113
+ ])
114
+
115
+ class JsonResult(TextTestResult):
116
+ res: PBTTestResult
117
+
118
+ @classmethod
119
+ def setProperties(cls, allProperties: Dict):
120
+ cls.res = dict()
121
+ for testCase in allProperties.values():
122
+ cls.res[getFullPropName(testCase)] = PropStatistic()
123
+
124
+ def flushResult(self, outfile):
125
+ json_res = dict()
126
+ for propName, propStatitic in self.res.items():
127
+ json_res[propName] = asdict(propStatitic)
128
+ with open(outfile, "w", encoding="utf-8") as fp:
129
+ json.dump(json_res, fp, indent=4)
130
+
131
+ def addExcuted(self, test: TestCase):
132
+ self.res[getFullPropName(test)].executed += 1
133
+
134
+ def addPrecondSatisfied(self, test: TestCase):
135
+ self.res[getFullPropName(test)].precond_satisfied += 1
136
+
137
+ def addFailure(self, test, err):
138
+ super().addFailure(test, err)
139
+ self.res[getFullPropName(test)].fail += 1
140
+
141
+ def addError(self, test, err):
142
+ super().addError(test, err)
143
+ self.res[getFullPropName(test)].error += 1
144
+
145
+
146
+ def activateFastbot(options: Options, port=None) -> threading.Thread:
147
+ """
148
+ activate fastbot.
149
+ :params: options: the running setting for fastbot
150
+ :params: port: the listening port for script driver
151
+ :return: the fastbot daemon thread
152
+ """
153
+ cur_dir = Path(__file__).parent
154
+ push_file(
155
+ Path.joinpath(cur_dir, "assets/monkeyq.jar"),
156
+ "/sdcard/monkeyq.jar",
157
+ device=options.serial
158
+ )
159
+ push_file(
160
+ Path.joinpath(cur_dir, "assets/fastbot-thirdpart.jar"),
161
+ "/sdcard/fastbot-thirdpart.jar",
162
+ device=options.serial,
163
+ )
164
+ push_file(
165
+ Path.joinpath(cur_dir, "assets/framework.jar"),
166
+ "/sdcard/framework.jar",
167
+ device=options.serial
168
+ )
169
+ push_file(
170
+ Path.joinpath(cur_dir, "assets/fastbot_libs/arm64-v8a"),
171
+ "/data/local/tmp",
172
+ device=options.serial
173
+ )
174
+ push_file(
175
+ Path.joinpath(cur_dir, "assets/fastbot_libs/armeabi-v7a"),
176
+ "/data/local/tmp",
177
+ device=options.serial
178
+ )
179
+ push_file(
180
+ Path.joinpath(cur_dir, "assets/fastbot_libs/x86"),
181
+ "/data/local/tmp",
182
+ device=options.serial
183
+ )
184
+ push_file(
185
+ Path.joinpath(cur_dir, "assets/fastbot_libs/x86_64"),
186
+ "/data/local/tmp",
187
+ device=options.serial
188
+ )
189
+
190
+ t = startFastbotService(options)
191
+ print("[INFO] Running Fastbot...", flush=True)
192
+
193
+ return t
194
+
195
+
196
+ def check_alive(port):
197
+ """
198
+ check if the script driver and proxy server are alive.
199
+ """
200
+ for _ in range(10):
201
+ sleep(2)
202
+ try:
203
+ requests.get(f"http://localhost:{port}/ping")
204
+ return
205
+ except requests.ConnectionError:
206
+ print("[INFO] waiting for connection.", flush=True)
207
+ pass
208
+ raise RuntimeError("Failed to connect fastbot")
209
+
210
+
211
+ def startFastbotService(options: Options) -> threading.Thread:
212
+ shell_command = [
213
+ "CLASSPATH=/sdcard/monkeyq.jar:/sdcard/framework.jar:/sdcard/fastbot-thirdpart.jar",
214
+ "exec", "app_process",
215
+ "/system/bin", "com.android.commands.monkey.Monkey",
216
+ "-p", *options.packageNames,
217
+ "--agent-u2" if options.agent == "u2" else "--agent",
218
+ "reuseq",
219
+ "--running-minutes", f"{options.running_mins}",
220
+ "--throttle", f"{options.throttle}",
221
+ "-v", "-v", "-v"
222
+ ]
223
+
224
+ full_cmd = ["adb"] + (["-s", options.serial] if options.serial else []) + ["shell"] + shell_command
225
+
226
+ outfile = open(LOGFILE, "w", encoding="utf-8", buffering=1)
227
+
228
+ print("[INFO] Options info: {}".format(asdict(options)), flush=True)
229
+ print("[INFO] Launching fastbot with shell command:\n{}".format(" ".join(full_cmd)), flush=True)
230
+ print("[INFO] Fastbot log will be saved to {}".format(outfile.name), flush=True)
231
+
232
+ # process handler
233
+ proc = subprocess.Popen(full_cmd, stdout=outfile, stderr=outfile)
234
+ t = threading.Thread(target=close_on_exit, args=(proc, outfile), daemon=True)
235
+ t.start()
236
+
237
+ return t
238
+
239
+
240
+ def close_on_exit(proc: subprocess.Popen, f: IO):
241
+ proc.wait()
242
+ f.close()
243
+
244
+
245
+ class KeaTestRunner(TextTestRunner):
246
+
247
+ resultclass: JsonResult
248
+ allProperties: PropertyStore
249
+ options: Options = None
250
+ _block_widgets_funcs = None
251
+
252
+ @classmethod
253
+ def setOptions(cls, options: Options):
254
+ if not isinstance(options.packageNames, list) and len(options.packageNames) > 0:
255
+ raise ValueError("packageNames should be given in a list.")
256
+ if options.Driver is not None and options.agent == "native":
257
+ print("[Warning] Can not use any Driver when runing native mode.", flush=True)
258
+ options.Driver = None
259
+ cls.options = options
260
+
261
+ def run(self, test):
262
+
263
+ self.allProperties = dict()
264
+ self.collectAllProperties(test)
265
+
266
+ if len(self.allProperties) == 0:
267
+ print("[Warning] No property has been found.", flush=True)
268
+
269
+ JsonResult.setProperties(self.allProperties)
270
+ self.resultclass = JsonResult
271
+
272
+ result: JsonResult = self._makeResult()
273
+ registerResult(result)
274
+ result.failfast = self.failfast
275
+ result.buffer = self.buffer
276
+ result.tb_locals = self.tb_locals
277
+
278
+ with warnings.catch_warnings():
279
+ if self.warnings:
280
+ # if self.warnings is set, use it to filter all the warnings
281
+ warnings.simplefilter(self.warnings)
282
+ # if the filter is 'default' or 'always', special-case the
283
+ # warnings from the deprecated unittest methods to show them
284
+ # no more than once per module, because they can be fairly
285
+ # noisy. The -Wd and -Wa flags can be used to bypass this
286
+ # only when self.warnings is None.
287
+ if self.warnings in ["default", "always"]:
288
+ warnings.filterwarnings(
289
+ "module",
290
+ category=DeprecationWarning,
291
+ message=r"Please use assert\w+ instead.",
292
+ )
293
+
294
+ t = activateFastbot(options=self.options)
295
+ log_watcher = LogWatcher(LOGFILE)
296
+ if self.options.agent == "native":
297
+ t.join()
298
+ else:
299
+ # initialize the result.json file
300
+ result.flushResult(outfile=RESFILE)
301
+ # setUp for the u2 driver
302
+ self.scriptDriver = self.options.Driver.getScriptDriver()
303
+ check_alive(port=self.scriptDriver.lport)
304
+
305
+ end_by_remote = False
306
+ step = 0
307
+ while step < self.options.maxStep:
308
+
309
+ step += 1
310
+ print("[INFO] Sending monkeyEvent {}".format(
311
+ f"({step} / {self.options.maxStep})" if self.options.maxStep != float("inf")
312
+ else f"({step})"
313
+ )
314
+ , flush=True)
315
+
316
+ try:
317
+ propsSatisfiedPrecond = self.getValidProperties()
318
+ except requests.ConnectionError:
319
+ print(
320
+ "[INFO] Exploration times up (--running-minutes)."
321
+ , flush=True)
322
+ end_by_remote = True
323
+ break
324
+
325
+ print(f"{len(propsSatisfiedPrecond)} precond satisfied.", flush=True)
326
+
327
+ # Go to the next round if no precond satisfied
328
+ if len(propsSatisfiedPrecond) == 0:
329
+ continue
330
+
331
+ # get the random probability p
332
+ p = random.random()
333
+ propsNameFilteredByP = []
334
+ # filter the properties according to the given p
335
+ for propName, test in propsSatisfiedPrecond.items():
336
+ result.addPrecondSatisfied(test)
337
+ if getattr(test, "p", 1) >= p:
338
+ propsNameFilteredByP.append(propName)
339
+
340
+ if len(propsNameFilteredByP) == 0:
341
+ print("Not executed any property due to probability.", flush=True)
342
+ continue
343
+
344
+ execPropName = random.choice(propsNameFilteredByP)
345
+ test = propsSatisfiedPrecond[execPropName]
346
+ # Dependency Injection. driver when doing scripts
347
+ self.scriptDriver = self.options.Driver.getScriptDriver()
348
+ setattr(test, self.options.driverName, self.scriptDriver)
349
+ print("execute property %s." % execPropName, flush=True)
350
+
351
+ result.addExcuted(test)
352
+ try:
353
+ test(result)
354
+ finally:
355
+ result.printErrors()
356
+
357
+ result.flushResult(outfile=RESFILE)
358
+
359
+ if not end_by_remote:
360
+ self.stopMonkey()
361
+ result.flushResult(outfile=RESFILE)
362
+
363
+ print(f"Finish sending monkey events.", flush=True)
364
+ log_watcher.close()
365
+ self.tearDown()
366
+
367
+ # Source code from unittest Runner
368
+ # process the result
369
+ expectedFails = unexpectedSuccesses = skipped = 0
370
+ try:
371
+ results = map(
372
+ len,
373
+ (result.expectedFailures, result.unexpectedSuccesses, result.skipped),
374
+ )
375
+ except AttributeError:
376
+ pass
377
+ else:
378
+ expectedFails, unexpectedSuccesses, skipped = results
379
+
380
+ infos = []
381
+ if not result.wasSuccessful():
382
+ self.stream.write("FAILED")
383
+ failed, errored = len(result.failures), len(result.errors)
384
+ if failed:
385
+ infos.append("failures=%d" % failed)
386
+ if errored:
387
+ infos.append("errors=%d" % errored)
388
+ else:
389
+ self.stream.write("OK")
390
+ if skipped:
391
+ infos.append("skipped=%d" % skipped)
392
+ if expectedFails:
393
+ infos.append("expected failures=%d" % expectedFails)
394
+ if unexpectedSuccesses:
395
+ infos.append("unexpected successes=%d" % unexpectedSuccesses)
396
+ if infos:
397
+ self.stream.writeln(" (%s)" % (", ".join(infos),))
398
+ else:
399
+ self.stream.write("\n")
400
+ self.stream.flush()
401
+ return result
402
+
403
+ def stepMonkey(self) -> str:
404
+ """
405
+ send a step monkey request to the server and get the xml string.
406
+ """
407
+ block_widgets = self._getBlockedWidgets()
408
+ r = requests.get(f"http://localhost:{self.scriptDriver.lport}/stepMonkey")
409
+
410
+ res = json.loads(r.content)
411
+ xml_raw = res["result"]
412
+ return xml_raw
413
+
414
+ def stopMonkey(self) -> str:
415
+ """
416
+ send a stop monkey request to the server and get the xml string.
417
+ """
418
+ r = requests.get(f"http://localhost:{self.scriptDriver.lport}/stopMonkey")
419
+
420
+ res = r.content.decode(encoding="utf-8")
421
+ print(f"[Server INFO] {res}", flush=True)
422
+
423
+ def getValidProperties(self) -> PropertyStore:
424
+
425
+ xml_raw = self.stepMonkey()
426
+ staticCheckerDriver = self.options.Driver.getStaticChecker(hierarchy=xml_raw)
427
+
428
+ validProps: PropertyStore = dict()
429
+ for propName, test in self.allProperties.items():
430
+ valid = True
431
+ prop = getattr(test, propName)
432
+ # check if all preconds passed
433
+ for precond in prop.preconds:
434
+ # Dependency injection. Static driver checker for precond
435
+ setattr(test, self.options.driverName, staticCheckerDriver)
436
+ # excecute the precond
437
+ if not precond(test):
438
+ valid = False
439
+ break
440
+ # if all the precond passed. make it the candidate prop.
441
+ if valid:
442
+ validProps[propName] = test
443
+ return validProps
444
+
445
+ def collectAllProperties(self, test: TestSuite):
446
+ """collect all the properties to prepare for PBT
447
+ """
448
+
449
+ def remove_setUp(testCase: TestCase):
450
+ """remove the setup function in PBT
451
+ """
452
+ def setUp(self): ...
453
+ testCase.setUp = types.MethodType(setUp, testCase)
454
+
455
+ def remove_tearDown(testCase: TestCase):
456
+ """remove the tearDown function in PBT
457
+ """
458
+ def tearDown(self): ...
459
+ testCase = types.MethodType(tearDown, testCase)
460
+
461
+ def iter_tests(suite):
462
+ for test in suite:
463
+ if isinstance(test, TestSuite):
464
+ yield from iter_tests(test)
465
+ else:
466
+ yield test
467
+
468
+ # Traverse the TestCase to get all properties
469
+ for t in iter_tests(test):
470
+ testMethodName = t._testMethodName
471
+ # get the test method name and check if it's a property
472
+ testMethod = getattr(t, testMethodName)
473
+ if hasattr(testMethod, PRECONDITIONS_MARKER):
474
+ # remove the hook func in its TestCase
475
+ remove_setUp(t)
476
+ remove_tearDown(t)
477
+ # save it into allProperties for PBT
478
+ self.allProperties[testMethodName] = t
479
+ print(f"[INFO] Load property: {getFullPropName(t)}", flush=True)
480
+
481
+ @property
482
+ def blockList(self):
483
+ if self._block_widgets_funcs is None:
484
+ self._block_widgets_funcs = list()
485
+ root_dir = getProjectRoot()
486
+ if root_dir is None or not os.path.exists(
487
+ file_block_widgets := root_dir / "configs" / "widget.block.py"
488
+ ):
489
+ print(f"[WARNING] widget.block.py not find", flush=True)
490
+
491
+
492
+ def __get_block_widgets_module():
493
+ import importlib.util
494
+ module_name = "block_widgets"
495
+ spec = importlib.util.spec_from_file_location(module_name, file_block_widgets)
496
+ mod = importlib.util.module_from_spec(spec)
497
+ spec.loader.exec_module(mod)
498
+ return mod
499
+
500
+ mod = __get_block_widgets_module()
501
+
502
+ import inspect
503
+ for func_name, func in inspect.getmembers(mod, inspect.isfunction):
504
+ if func_name.startswith("block_") or func_name == "global_block_widgets":
505
+ if getattr(func, PRECONDITIONS_MARKER, None) is None:
506
+ if func_name.startswith("block_"):
507
+ logger.warning(f"No precondition in block widget function: {func_name}. Default globally active.")
508
+ setattr(func, PRECONDITIONS_MARKER, (lambda d: True, ))
509
+ self._block_widgets_funcs.append(func)
510
+
511
+ return self._block_widgets_funcs
512
+
513
+ def _getBlockedWidgets(self):
514
+ blocked_widgets = list()
515
+ for func in self.blockList:
516
+ try:
517
+ script_driver = self.options.Driver.getScriptDriver()
518
+ preconds = getattr(func, PRECONDITIONS_MARKER)
519
+ if all([precond(script_driver) for precond in preconds]):
520
+ _widgets = func(self.options.Driver.getStaticChecker())
521
+ if not isinstance(_widgets, list):
522
+ _widgets = [_widgets]
523
+ blocked_widgets.extend([
524
+ w._getXPath(w.selector) for w in _widgets
525
+ ])
526
+ except Exception as e:
527
+ logger.error(f"error when getting blocked widgets: {e}")
528
+ import traceback
529
+ traceback.print_exc()
530
+
531
+ return blocked_widgets
532
+
533
+ def tearDown(self):
534
+ # TODO Add tearDown method (remove local port, etc.)
535
+ pass
kea2/kea_launcher.py ADDED
@@ -0,0 +1,135 @@
1
+ import argparse
2
+ from typing import List
3
+ import unittest
4
+
5
+ def _set_driver_parser(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]"):
6
+ parser = subparsers.add_parser("driver", help="Driver Settings")
7
+ parser.add_argument(
8
+ "-s",
9
+ "--serial",
10
+ dest="serial",
11
+ type=str,
12
+ help="The serial of your device. Can be found with `adb devices`",
13
+ )
14
+
15
+ parser.add_argument(
16
+ "-p",
17
+ "--packages",
18
+ dest="package_names",
19
+ nargs="+",
20
+ type=str,
21
+ required=True,
22
+ help="The target package names com.example.app",
23
+ )
24
+
25
+ parser.add_argument(
26
+ "--agent",
27
+ dest="agent",
28
+ type=str,
29
+ default="u2",
30
+ choices=["native", "u2"],
31
+ help="Running native fastbot or u2-fastbot. (Only u2-fastbot support PBT)",
32
+ )
33
+
34
+ parser.add_argument(
35
+ "--running-minutes",
36
+ dest="running_minutes",
37
+ type=int,
38
+ required=False,
39
+ help="Time to run fastbot",
40
+ )
41
+
42
+ parser.add_argument(
43
+ "--max-step",
44
+ dest="max_step",
45
+ type=int,
46
+ required=False,
47
+ help="maxium monkey events count to send",
48
+ )
49
+
50
+ parser.add_argument(
51
+ "--throttle",
52
+ dest="throttle_ms",
53
+ type=int,
54
+ required=False,
55
+ help="The pause between two monkey event.",
56
+ )
57
+
58
+ parser.add_argument(
59
+ "--driver-name",
60
+ dest="driver_name",
61
+ type=str,
62
+ required=False,
63
+ help="The name of driver in script.",
64
+ )
65
+
66
+ parser.add_argument(
67
+ "extra",
68
+ nargs=argparse.REMAINDER,
69
+ help="Extra args for unittest <args>",
70
+ )
71
+
72
+
73
+ def unittest_info_logger(args):
74
+ if args.agent == "native":
75
+ print("[Warning] Property not availble in native agent.", flush=True)
76
+ if args.extra and args.extra[0] == "unittest":
77
+ print("Captured unittest args:", args.extra, flush=True)
78
+
79
+
80
+ def driver_info_logger(args):
81
+ print("[INFO] Driver Settings:", flush=True)
82
+ if args.serial:
83
+ print(" serial:", args.serial, flush=True)
84
+ if args.package_names:
85
+ print(" package_names:", args.package_names, flush=True)
86
+ if args.agent:
87
+ print(" agent:", args.agent, flush=True)
88
+ if args.running_minutes:
89
+ print(" running_minutes:", args.running_minutes, flush=True)
90
+ if args.throttle_ms:
91
+ print(" throttle_ms:", args.throttle_ms, flush=True)
92
+
93
+
94
+ def parse_args(argv: List):
95
+ parser = argparse.ArgumentParser(description="Kea2")
96
+ subparsers = parser.add_subparsers(dest="command", required=True)
97
+
98
+ _set_driver_parser(subparsers)
99
+ if len(argv) == 0:
100
+ argv.append("-h")
101
+ args = parser.parse_args(argv)
102
+ driver_info_logger(args)
103
+ unittest_info_logger(args)
104
+ return args
105
+
106
+ def run(argv=None):
107
+ import sys
108
+ if argv is None:
109
+ argv = sys.argv
110
+ args = parse_args(argv[1:])
111
+
112
+ from kea2 import KeaTestRunner, Options
113
+ from kea2.u2Driver import U2Driver
114
+ options = Options(
115
+ agent=args.agent,
116
+ driverName=args.driver_name,
117
+ Driver=U2Driver,
118
+ packageNames=args.package_names,
119
+ serial=args.serial,
120
+ running_mins=args.running_minutes if args.running_minutes else 10,
121
+ maxStep=args.max_step if args.max_step else 500,
122
+ throttle=args.throttle_ms if args.throttle_ms else 200
123
+ )
124
+
125
+ KeaTestRunner.setOptions(options)
126
+ unittest_args = []
127
+ if args.extra and args.extra[0] == "unittest":
128
+ unittest_args = args.extra[1:]
129
+ sys.argv = ["python3 -m unittest"] + unittest_args
130
+
131
+ unittest.main(module=None, testRunner=KeaTestRunner)
132
+
133
+
134
+ if __name__ == "__main__":
135
+ run()