koder aka kdanilov | 4643fd6 | 2015-02-10 16:20:13 -0800 | [diff] [blame] | 1 | import abc |
koder aka kdanilov | bc2c898 | 2015-06-13 02:50:43 +0300 | [diff] [blame] | 2 | import time |
| 3 | import logging |
koder aka kdanilov | 4643fd6 | 2015-02-10 16:20:13 -0800 | [diff] [blame] | 4 | import os.path |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 5 | from typing import Any, List, Optional, Callable, Tuple, Iterable, cast |
koder aka kdanilov | 652cd80 | 2015-04-13 12:21:07 +0300 | [diff] [blame] | 6 | |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 7 | from concurrent.futures import ThreadPoolExecutor, wait, Future |
koder aka kdanilov | 4643fd6 | 2015-02-10 16:20:13 -0800 | [diff] [blame] | 8 | |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 9 | from ..utils import StopTestError, get_time_interval_printable_info |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 10 | from ..node_interfaces import IRPCNode |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 11 | from ..result_classes import TestSuiteConfig, TestJobConfig, JobMetrics, TimeSeries, IResultStorage |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 12 | |
koder aka kdanilov | 4af1c1d | 2015-05-18 15:48:58 +0300 | [diff] [blame] | 13 | |
koder aka kdanilov | bc2c898 | 2015-06-13 02:50:43 +0300 | [diff] [blame] | 14 | logger = logging.getLogger("wally") |
koder aka kdanilov | 88407ff | 2015-05-26 15:35:57 +0300 | [diff] [blame] | 15 | |
| 16 | |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 17 | __doc__ = "Contains base classes for performance tests" |
| 18 | |
| 19 | |
koder aka kdanilov | 7f59d56 | 2016-12-26 01:34:23 +0200 | [diff] [blame] | 20 | class PerfTest(metaclass=abc.ABCMeta): |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 21 | """Base class for all tests""" |
| 22 | name = None # type: str |
| 23 | max_retry = 3 |
| 24 | retry_time = 30 |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 25 | job_config_cls = None # type: type |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 26 | |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 27 | def __init__(self, storage: IResultStorage, suite: TestSuiteConfig, on_idle: Callable[[], None] = None) -> None: |
| 28 | self.suite = suite |
koder aka kdanilov | e2de58c | 2015-04-24 22:59:36 +0300 | [diff] [blame] | 29 | self.stop_requested = False |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 30 | self.sorted_nodes_ids = sorted(node.node_id for node in self.suite.nodes) |
koder aka kdanilov | 7f59d56 | 2016-12-26 01:34:23 +0200 | [diff] [blame] | 31 | self.on_idle = on_idle |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 32 | self.storage = storage |
koder aka kdanilov | e2de58c | 2015-04-24 22:59:36 +0300 | [diff] [blame] | 33 | |
koder aka kdanilov | 3b4da8b | 2016-10-17 00:17:53 +0300 | [diff] [blame] | 34 | def request_stop(self) -> None: |
koder aka kdanilov | e2de58c | 2015-04-24 22:59:36 +0300 | [diff] [blame] | 35 | self.stop_requested = True |
koder aka kdanilov | 2066daf | 2015-04-23 21:05:41 +0300 | [diff] [blame] | 36 | |
koder aka kdanilov | 3b4da8b | 2016-10-17 00:17:53 +0300 | [diff] [blame] | 37 | def join_remote(self, path: str) -> str: |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 38 | return os.path.join(self.suite.remote_dir, path) |
koder aka kdanilov | 4500a5f | 2015-04-17 16:55:17 +0300 | [diff] [blame] | 39 | |
koder aka kdanilov | 4af1c1d | 2015-05-18 15:48:58 +0300 | [diff] [blame] | 40 | @abc.abstractmethod |
koder aka kdanilov | bbbe1dc | 2016-12-20 01:19:56 +0200 | [diff] [blame] | 41 | def run(self) -> None: |
koder aka kdanilov | 4643fd6 | 2015-02-10 16:20:13 -0800 | [diff] [blame] | 42 | pass |
| 43 | |
koder aka kdanilov | bc2c898 | 2015-06-13 02:50:43 +0300 | [diff] [blame] | 44 | @abc.abstractmethod |
koder aka kdanilov | 39e449e | 2016-12-17 15:15:26 +0200 | [diff] [blame] | 45 | def format_for_console(self, data: Any) -> str: |
koder aka kdanilov | ec1b973 | 2015-04-23 20:43:29 +0300 | [diff] [blame] | 46 | pass |
| 47 | |
koder aka kdanilov | 4643fd6 | 2015-02-10 16:20:13 -0800 | [diff] [blame] | 48 | |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 49 | class ThreadedTest(PerfTest, metaclass=abc.ABCMeta): |
| 50 | """Base class for tests, which spawn separated thread for each node""" |
| 51 | |
| 52 | # max allowed time difference between starts and stops of run of the same test on different test nodes |
| 53 | # used_max_diff = max((min_run_time * max_rel_time_diff), max_time_diff) |
| 54 | max_time_diff = 5 |
| 55 | max_rel_time_diff = 0.05 |
koder aka kdanilov | ffaf48d | 2016-12-27 02:25:29 +0200 | [diff] [blame] | 56 | load_profile_name = None # type: str |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 57 | |
koder aka kdanilov | 7f59d56 | 2016-12-26 01:34:23 +0200 | [diff] [blame] | 58 | def __init__(self, *args, **kwargs) -> None: |
| 59 | PerfTest.__init__(self, *args, **kwargs) |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 60 | self.job_configs = None # type: List[TestJobConfig] |
koder aka kdanilov | bc2c898 | 2015-06-13 02:50:43 +0300 | [diff] [blame] | 61 | |
| 62 | @abc.abstractmethod |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 63 | def get_expected_runtime(self, iter_cfg: TestJobConfig) -> Optional[int]: |
koder aka kdanilov | bc2c898 | 2015-06-13 02:50:43 +0300 | [diff] [blame] | 64 | pass |
| 65 | |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 66 | def get_not_done_jobs(self) -> Iterable[TestJobConfig]: |
| 67 | jobs_map = {job.storage_id: job for job in self.job_configs} |
| 68 | already_in_storage = set() |
| 69 | for db_config in cast(List[TestJobConfig], self.storage.iter_job(self.suite)): |
| 70 | if db_config.storage_id in jobs_map: |
| 71 | job = jobs_map[db_config.storage_id] |
| 72 | if job != db_config: |
| 73 | logger.error("Test info at '%s.%s' is not equal to expected config for iteration %s.%s." + |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 74 | " Maybe configuration was changed before test was restarted. " + |
| 75 | "DB cfg is:\n %s\nExpected cfg is:\n %s\nFix DB or rerun test from beginning", |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 76 | self.suite.storage_id, job.storage_id, self.name, job.summary, |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 77 | str(db_config).replace("\n", "\n "), |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 78 | str(job).replace("\n", "\n ")) |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 79 | raise StopTestError() |
koder aka kdanilov | bbbe1dc | 2016-12-20 01:19:56 +0200 | [diff] [blame] | 80 | |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 81 | logger.info("Test iteration %s.%s found in storage and will be skipped", self.name, job.summary) |
| 82 | already_in_storage.add(db_config.storage_id) |
| 83 | |
| 84 | return [job for job in self.job_configs if job.storage_id not in already_in_storage] |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 85 | |
koder aka kdanilov | bbbe1dc | 2016-12-20 01:19:56 +0200 | [diff] [blame] | 86 | def run(self) -> None: |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 87 | self.storage.put_or_check_suite(self.suite) |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 88 | |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 89 | not_in_storage = list(self.get_not_done_jobs()) |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 90 | if not not_in_storage: |
| 91 | logger.info("All test iteration in storage already. Skip test") |
| 92 | return |
| 93 | |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 94 | logger.debug("Run test %s with profile %r on nodes %s.", self.name, |
| 95 | self.load_profile_name, |
| 96 | ",".join(self.sorted_nodes_ids)) |
koder aka kdanilov | 23e6bdf | 2016-12-24 02:18:54 +0200 | [diff] [blame] | 97 | logger.debug("Prepare nodes") |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 98 | |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 99 | |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 100 | with ThreadPoolExecutor(len(self.suite.nodes)) as pool: |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 101 | # config nodes |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 102 | list(pool.map(self.config_node, self.suite.nodes)) |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 103 | |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 104 | run_times = list(map(self.get_expected_runtime, not_in_storage)) |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 105 | |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 106 | if None not in run_times: |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 107 | # +5% - is a rough estimation for additional operations |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 108 | expected_run_time = int(sum(run_times) * 1.05) |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 109 | |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 110 | exec_time_s, end_dt_s = get_time_interval_printable_info(expected_run_time) |
| 111 | logger.info("Entire test should takes around %s and finished at %s", exec_time_s, end_dt_s) |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 112 | |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 113 | for job in not_in_storage: |
| 114 | results = [] # type: List[TimeSeries] |
| 115 | for retry_idx in range(self.max_retry): |
| 116 | logger.debug("Prepare job %s", job.summary) |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 117 | |
koder aka kdanilov | 23e6bdf | 2016-12-24 02:18:54 +0200 | [diff] [blame] | 118 | # prepare nodes for new iterations |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 119 | wait([pool.submit(self.prepare_iteration, node, job) for node in self.suite.nodes]) |
koder aka kdanilov | 23e6bdf | 2016-12-24 02:18:54 +0200 | [diff] [blame] | 120 | |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 121 | expected_job_time = self.get_expected_runtime(job) |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 122 | exec_time_s, end_dt_s = get_time_interval_printable_info(expected_job_time) |
| 123 | logger.info("Job should takes around %s and finished at %s", exec_time_s, end_dt_s) |
| 124 | |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 125 | jfutures = [pool.submit(self.run_iteration, node, job) for node in self.suite.nodes] |
| 126 | failed = False |
| 127 | for future in jfutures: |
| 128 | try: |
| 129 | results.extend(future.result()) |
| 130 | except EnvironmentError: |
| 131 | failed = True |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 132 | |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 133 | if not failed: |
| 134 | break |
| 135 | |
| 136 | if self.max_retry - 1 == retry_idx: |
| 137 | logger.exception("Fio failed") |
| 138 | raise StopTestError() |
| 139 | |
| 140 | logger.exception("During fio run") |
| 141 | logger.info("Sleeping %ss and retrying job", self.retry_time) |
| 142 | time.sleep(self.retry_time) |
| 143 | results = [] |
| 144 | |
| 145 | # per node jobs start and stop times |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 146 | start_times = [] # type: List[int] |
| 147 | stop_times = [] # type: List[int] |
| 148 | |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 149 | for ts in results: |
| 150 | self.storage.put_ts(ts) |
| 151 | if len(ts.times) >= 2: # type: ignore |
| 152 | start_times.append(ts.times[0]) |
| 153 | stop_times.append(ts.times[-1]) |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 154 | |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 155 | if len(start_times) > 0: |
| 156 | min_start_time = min(start_times) |
| 157 | max_start_time = max(start_times) |
| 158 | min_stop_time = min(stop_times) |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 159 | |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 160 | max_allowed_time_diff = int((min_stop_time - max_start_time) * self.max_rel_time_diff) |
| 161 | max_allowed_time_diff = max(max_allowed_time_diff, self.max_time_diff) |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 162 | |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 163 | if min_start_time + self.max_time_diff < max_allowed_time_diff: |
| 164 | logger.warning("Too large difference in %s:%s start time - %s. " + |
| 165 | "Max recommended difference is %s", |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 166 | self.name, job.summary, |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 167 | max_start_time - min_start_time, self.max_time_diff) |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 168 | |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 169 | if min_stop_time + self.max_time_diff < max_allowed_time_diff: |
| 170 | logger.warning("Too large difference in %s:%s stop time - %s. " + |
| 171 | "Max recommended difference is %s", |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 172 | self.name, job.summary, |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 173 | max_start_time - min_start_time, self.max_time_diff) |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 174 | |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 175 | job.reliable_info_starts_at = max_start_time |
| 176 | job.reliable_info_stops_at = min_stop_time |
| 177 | |
| 178 | self.storage.put_job(self.suite, job) |
koder aka kdanilov | f286517 | 2016-12-30 03:35:11 +0200 | [diff] [blame] | 179 | self.storage.sync() |
koder aka kdanilov | 7f59d56 | 2016-12-26 01:34:23 +0200 | [diff] [blame] | 180 | |
| 181 | if self.on_idle is not None: |
| 182 | self.on_idle() |
| 183 | |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 184 | @abc.abstractmethod |
| 185 | def config_node(self, node: IRPCNode) -> None: |
| 186 | pass |
| 187 | |
| 188 | @abc.abstractmethod |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 189 | def prepare_iteration(self, node: IRPCNode, job: TestJobConfig) -> None: |
koder aka kdanilov | 23e6bdf | 2016-12-24 02:18:54 +0200 | [diff] [blame] | 190 | pass |
| 191 | |
| 192 | @abc.abstractmethod |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 193 | def run_iteration(self, node: IRPCNode, job: TestJobConfig) -> List[TimeSeries]: |
koder aka kdanilov | bc2c898 | 2015-06-13 02:50:43 +0300 | [diff] [blame] | 194 | pass |
| 195 | |
| 196 | |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 197 | class TwoScriptTest(ThreadedTest, metaclass=abc.ABCMeta): |
| 198 | def __init__(self, *dt, **mp) -> None: |
koder aka kdanilov | bc2c898 | 2015-06-13 02:50:43 +0300 | [diff] [blame] | 199 | ThreadedTest.__init__(self, *dt, **mp) |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 200 | self.prerun_script = self.suite.params['prerun_script'] |
| 201 | self.run_script = self.suite.params['run_script'] |
| 202 | self.prerun_tout = self.suite.params.get('prerun_tout', 3600) |
| 203 | self.run_tout = self.suite.params.get('run_tout', 3600) |
| 204 | # TODO: fix job_configs field |
| 205 | raise NotImplementedError("Fix job configs") |
Yulia Portnova | 7ddfa73 | 2015-02-24 17:32:58 +0200 | [diff] [blame] | 206 | |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 207 | def get_expected_runtime(self, job: TestJobConfig) -> Optional[int]: |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 208 | return None |
Yulia Portnova | 7ddfa73 | 2015-02-24 17:32:58 +0200 | [diff] [blame] | 209 | |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 210 | def config_node(self, node: IRPCNode) -> None: |
| 211 | node.copy_file(self.run_script, self.join_remote(self.run_script)) |
| 212 | node.copy_file(self.prerun_script, self.join_remote(self.prerun_script)) |
koder aka kdanilov | bc2c898 | 2015-06-13 02:50:43 +0300 | [diff] [blame] | 213 | |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 214 | cmd = self.join_remote(self.prerun_script) |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 215 | cmd += ' ' + self.suite.params.get('prerun_opts', '') |
koder aka kdanilov | 3b4da8b | 2016-10-17 00:17:53 +0300 | [diff] [blame] | 216 | node.run(cmd, timeout=self.prerun_tout) |
Yulia Portnova | 7ddfa73 | 2015-02-24 17:32:58 +0200 | [diff] [blame] | 217 | |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 218 | def prepare_iteration(self, node: IRPCNode, job: TestJobConfig) -> None: |
koder aka kdanilov | 23e6bdf | 2016-12-24 02:18:54 +0200 | [diff] [blame] | 219 | pass |
| 220 | |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 221 | def run_iteration(self, node: IRPCNode, job: TestJobConfig) -> List[TimeSeries]: |
koder aka kdanilov | 23e6bdf | 2016-12-24 02:18:54 +0200 | [diff] [blame] | 222 | # TODO: have to store logs |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 223 | cmd = self.join_remote(self.run_script) |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 224 | cmd += ' ' + self.suite.params.get('run_opts', '') |
koder aka kdanilov | 23e6bdf | 2016-12-24 02:18:54 +0200 | [diff] [blame] | 225 | return self.parse_results(node.run(cmd, timeout=self.run_tout)) |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 226 | |
| 227 | @abc.abstractmethod |
koder aka kdanilov | 108ac36 | 2017-01-19 20:17:16 +0200 | [diff] [blame^] | 228 | def parse_results(self, data: str) -> List[TimeSeries]: |
koder aka kdanilov | 7022706 | 2016-11-26 23:23:21 +0200 | [diff] [blame] | 229 | pass |
| 230 | |