Set analysis version using version_utils string parser
[deliverable/lttng-analyses.git] / lttnganalyses / cli / command.py
CommitLineData
4ed24f86
JD
1# The MIT License (MIT)
2#
a3fa57c0 3# Copyright (C) 2015 - Julien Desfossez <jdesfossez@efficios.com>
cee855a2 4# 2015 - Philippe Proulx <pproulx@efficios.com>
0b250a71 5# 2015 - Antoine Busque <abusque@efficios.com>
4ed24f86
JD
6#
7# Permission is hereby granted, free of charge, to any person obtaining a copy
8# of this software and associated documentation files (the "Software"), to deal
9# in the Software without restriction, including without limitation the rights
10# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11# copies of the Software, and to permit persons to whom the Software is
12# furnished to do so, subject to the following conditions:
13#
14# The above copyright notice and this permission notice shall be included in
15# all copies or substantial portions of the Software.
16#
17# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23# SOFTWARE.
24
323b3fd6 25import argparse
a0acc08c 26import json
4ac5e240 27import os
a0acc08c 28import re
0b250a71
AB
29import sys
30import subprocess
31from babeltrace import TraceCollection
9079847d 32from . import mi, progressbar
9463174b 33from .. import __version__
0b250a71 34from ..core import analysis
9079847d
AB
35from ..common import (
36 format_utils, parse_utils, time_utils, trace_utils, version_utils
37)
0b250a71 38from ..linuxautomaton import automaton
323b3fd6
PP
39
40
41class Command:
a0acc08c
PP
42 _MI_BASE_TAGS = ['linux-kernel', 'lttng-analyses']
43 _MI_AUTHORS = [
44 'Julien Desfossez',
45 'Antoine Busque',
46 'Philippe Proulx',
47 ]
48 _MI_URL = 'https://github.com/lttng/lttng-analyses'
9463174b 49 _VERSION = version_utils.Version.new_from_string(__version__)
a0acc08c
PP
50
51 def __init__(self, mi_mode=False):
b6d9132b
AB
52 self._analysis = None
53 self._analysis_conf = None
54 self._args = None
55 self._handles = None
56 self._traces = None
a0acc08c
PP
57 self._ticks = 0
58 self._mi_mode = mi_mode
5bc26da4
PP
59 self._run_step('create automaton', self._create_automaton)
60 self._run_step('setup MI', self._mi_setup)
a0acc08c
PP
61
62 @property
63 def mi_mode(self):
64 return self._mi_mode
323b3fd6 65
5bc26da4 66 def _run_step(self, action_title, fn):
74d112b5 67 try:
5bc26da4 68 fn()
74d112b5 69 except KeyboardInterrupt:
5bc26da4 70 self._print('Cancelled by user')
74d112b5 71 sys.exit(0)
5bc26da4
PP
72 except Exception as e:
73 self._gen_error('Cannot {}: {}'.format(action_title, e))
74
75 def run(self):
76 self._run_step('parse arguments', self._parse_args)
77 self._run_step('open trace', self._open_trace)
78 self._run_step('create analysis', self._create_analysis)
79
78f6647f 80 if not self._mi_mode or not self._args.test_compatibility:
5bc26da4
PP
81 self._run_step('run analysis', self._run_analysis)
82
83 self._run_step('close trace', self._close_trace)
b6d9132b 84
8311b968
PP
85 def _mi_error(self, msg, code=None):
86 print(json.dumps(mi.get_error(msg, code)))
87
88 def _non_mi_error(self, msg):
d6c76c60
PP
89 try:
90 import termcolor
91
92 msg = termcolor.colored(msg, 'red', attrs=['bold'])
05684c5e 93 except ImportError:
d6c76c60
PP
94 pass
95
323b3fd6 96 print(msg, file=sys.stderr)
8311b968
PP
97
98 def _error(self, msg, code, exit_code=1):
99 if self._mi_mode:
100 self._mi_error(msg)
101 else:
102 self._non_mi_error(msg)
103
323b3fd6
PP
104 sys.exit(exit_code)
105
106 def _gen_error(self, msg, exit_code=1):
107 self._error('Error: {}'.format(msg), exit_code)
108
109 def _cmdline_error(self, msg, exit_code=1):
110 self._error('Command line error: {}'.format(msg), exit_code)
111
a0acc08c
PP
112 def _print(self, msg):
113 if not self._mi_mode:
114 print(msg)
115
116 def _mi_create_result_table(self, table_class_name, begin, end,
117 subtitle=None):
118 return mi.ResultTable(self._mi_table_classes[table_class_name],
119 begin, end, subtitle)
120
121 def _mi_setup(self):
122 self._mi_table_classes = {}
123
124 for tc_tuple in self._MI_TABLE_CLASSES:
125 table_class = mi.TableClass(tc_tuple[0], tc_tuple[1], tc_tuple[2])
126 self._mi_table_classes[table_class.name] = table_class
127
128 self._mi_clear_result_tables()
129
130 def _mi_print_metadata(self):
131 tags = self._MI_BASE_TAGS + self._MI_TAGS
9463174b 132 infos = mi.get_metadata(version=self._VERSION, title=self._MI_TITLE,
a0acc08c
PP
133 description=self._MI_DESCRIPTION,
134 authors=self._MI_AUTHORS, url=self._MI_URL,
135 tags=tags,
136 table_classes=self._mi_table_classes.values())
137 print(json.dumps(infos))
138
139 def _mi_append_result_table(self, result_table):
140 if not result_table or not result_table.rows:
141 return
142
143 tc_name = result_table.table_class.name
144 self._mi_get_result_tables(tc_name).append(result_table)
145
146 def _mi_append_result_tables(self, result_tables):
147 if not result_tables:
148 return
149
150 for result_table in result_tables:
151 self._mi_append_result_table(result_table)
152
153 def _mi_clear_result_tables(self):
154 self._result_tables = {}
155
156 def _mi_get_result_tables(self, table_class_name):
157 if table_class_name not in self._result_tables:
158 self._result_tables[table_class_name] = []
159
160 return self._result_tables[table_class_name]
161
162 def _mi_print(self):
163 results = []
164
165 for result_tables in self._result_tables.values():
166 for result_table in result_tables:
167 results.append(result_table.to_native_object())
168
169 obj = {
170 'results': results,
171 }
172
173 print(json.dumps(obj))
174
175 def _create_summary_result_tables(self):
176 pass
177
bd3cd7c5
JD
178 def _open_trace(self):
179 traces = TraceCollection()
b6d9132b 180 handles = traces.add_traces_recursive(self._args.path, 'ctf')
ced36aab 181 if handles == {}:
b6d9132b 182 self._gen_error('Failed to open ' + self._args.path, -1)
ced36aab 183 self._handles = handles
bd3cd7c5 184 self._traces = traces
dd2efe70
PP
185 self._ts_begin = traces.timestamp_begin
186 self._ts_end = traces.timestamp_end
652bc6b7 187 self._process_date_args()
ee6a5866 188 self._read_tracer_version()
b6d9132b 189 if not self._args.skip_validation:
d3014022 190 self._check_lost_events()
bd3cd7c5
JD
191
192 def _close_trace(self):
ced36aab
AB
193 for handle in self._handles.values():
194 self._traces.remove_trace(handle)
bd3cd7c5 195
ee6a5866 196 def _read_tracer_version(self):
4ac5e240 197 kernel_path = None
2dca9c55
JD
198 # remove the trailing /
199 while self._args.path.endswith('/'):
200 self._args.path = self._args.path[:-1]
4ac5e240
AB
201 for root, _, _ in os.walk(self._args.path):
202 if root.endswith('kernel'):
203 kernel_path = root
204 break
205
206 if kernel_path is None:
207 self._gen_error('Could not find kernel trace directory')
208
ee6a5866 209 try:
0349f942 210 ret, metadata = subprocess.getstatusoutput(
4ac5e240 211 'babeltrace -o ctf-metadata "%s"' % kernel_path)
ee6a5866
AB
212 except subprocess.CalledProcessError:
213 self._gen_error('Cannot run babeltrace on the trace, cannot read'
214 ' tracer version')
215
0349f942
JD
216 # fallback to reading the text metadata if babeltrace failed to
217 # output the CTF metadata
218 if ret != 0:
219 try:
220 metadata = subprocess.getoutput(
221 'cat "%s"' % os.path.join(kernel_path, 'metadata'))
222 except subprocess.CalledProcessError:
223 self._gen_error('Cannot read the metadata of the trace, cannot'
224 'extract tracer version')
225
226 major_match = re.search(r'tracer_major = "*(\d+)"*', metadata)
227 minor_match = re.search(r'tracer_minor = "*(\d+)"*', metadata)
228 patch_match = re.search(r'tracer_patchlevel = "*(\d+)"*', metadata)
ee6a5866
AB
229
230 if not major_match or not minor_match or not patch_match:
231 self._gen_error('Malformed metadata, cannot read tracer version')
232
233 self.state.tracer_version = version_utils.Version(
234 int(major_match.group(1)),
235 int(minor_match.group(1)),
236 int(patch_match.group(1)),
237 )
238
d3014022 239 def _check_lost_events(self):
73f9d005
PP
240 msg = 'Checking the trace for lost events...'
241 self._print(msg)
242
243 if self._mi_mode and self._args.output_progress:
244 mi.print_progress(0, msg)
245
d3014022 246 try:
e0bc16fe 247 subprocess.check_output('babeltrace "%s"' % self._args.path,
d3014022
JD
248 shell=True)
249 except subprocess.CalledProcessError:
b9f05f8d
AB
250 self._gen_error('Cannot run babeltrace on the trace, cannot verify'
251 ' if events were lost during the trace recording')
a0acc08c
PP
252
253 def _pre_analysis(self):
254 pass
255
256 def _post_analysis(self):
257 if not self._mi_mode:
258 return
259
260 if self._ticks > 1:
261 self._create_summary_result_tables()
262
263 self._mi_print()
d3014022 264
73f9d005 265 def _pb_setup(self):
dd2efe70
PP
266 if self._args.no_progress:
267 return
268
269 ts_end = self._ts_end
270
271 if self._analysis_conf.end_ts is not None:
272 ts_end = self._analysis_conf.end_ts
73f9d005 273
73f9d005 274 if self._mi_mode:
dd2efe70 275 cls = progressbar.MiProgress
73f9d005 276 else:
dd2efe70
PP
277 cls = progressbar.FancyProgressBar
278
279 self._progress = cls(self._ts_begin, ts_end, self._args.path,
280 self._args.progress_use_size)
281
282 def _pb_update(self, event):
283 if self._args.no_progress:
284 return
285
286 self._progress.update(event)
73f9d005
PP
287
288 def _pb_finish(self):
dd2efe70
PP
289 if self._args.no_progress:
290 return
291
292 self._progress.finalize()
73f9d005 293
b6d9132b 294 def _run_analysis(self):
a0acc08c 295 self._pre_analysis()
73f9d005 296 self._pb_setup()
b6d9132b 297
bd3cd7c5 298 for event in self._traces.events:
dd2efe70 299 self._pb_update(event)
bd3cd7c5 300 self._analysis.process_event(event)
b6d9132b
AB
301 if self._analysis.ended:
302 break
47ba125c 303 self._automaton.process_event(event)
bd3cd7c5 304
73f9d005 305 self._pb_finish()
b6d9132b 306 self._analysis.end()
a0acc08c 307 self._post_analysis()
bd3cd7c5 308
3664e4b0 309 def _print_date(self, begin_ns, end_ns):
9079847d
AB
310 time_range_str = format_utils.format_time_range(
311 begin_ns, end_ns, print_date=True, gmt=self._args.gmt
312 )
313 date = 'Timerange: {}'.format(time_range_str)
314
a0acc08c 315 self._print(date)
3664e4b0 316
9079847d
AB
317 def _format_timestamp(self, timestamp):
318 return format_utils.format_timestamp(
319 timestamp, print_date=self._args.multi_day, gmt=self._args.gmt
320 )
321
dbbdd963
PP
322 def _get_uniform_freq_values(self, durations):
323 if self._args.uniform_step is not None:
324 return (self._args.uniform_min, self._args.uniform_max,
325 self._args.uniform_step)
326
327 if self._args.min is not None:
328 self._args.uniform_min = self._args.min
329 else:
330 self._args.uniform_min = min(durations)
331 if self._args.max is not None:
332 self._args.uniform_max = self._args.max
333 else:
334 self._args.uniform_max = max(durations)
335
336 # ns to µs
337 self._args.uniform_min /= 1000
338 self._args.uniform_max /= 1000
339 self._args.uniform_step = (
340 (self._args.uniform_max - self._args.uniform_min) /
341 self._args.freq_resolution
342 )
343
344 return self._args.uniform_min, self._args.uniform_max, \
650e7f57 345 self._args.uniform_step
dbbdd963 346
bd3cd7c5 347 def _validate_transform_common_args(self, args):
83ad157b
AB
348 refresh_period_ns = None
349 if args.refresh is not None:
350 try:
9079847d 351 refresh_period_ns = parse_utils.parse_duration(args.refresh)
83ad157b
AB
352 except ValueError as e:
353 self._cmdline_error(str(e))
354
b6d9132b 355 self._analysis_conf = analysis.AnalysisConfig()
83ad157b 356 self._analysis_conf.refresh_period = refresh_period_ns
43a3c04c
AB
357 self._analysis_conf.period_begin_ev_name = args.period_begin
358 self._analysis_conf.period_end_ev_name = args.period_end
05684c5e 359 self._analysis_conf.period_begin_key_fields = \
007d3fe0 360 args.period_begin_key.split(',')
05684c5e
AB
361
362 if args.period_end_key:
363 self._analysis_conf.period_end_key_fields = \
007d3fe0 364 args.period_end_key.split(',')
05684c5e
AB
365 else:
366 self._analysis_conf.period_end_key_fields = \
007d3fe0 367 self._analysis_conf.period_begin_key_fields
05684c5e
AB
368
369 if args.period_key_value:
370 self._analysis_conf.period_key_value = \
007d3fe0 371 tuple(args.period_key_value.split(','))
05684c5e 372
a621ba35
AB
373 if args.cpu:
374 self._analysis_conf.cpu_list = args.cpu.split(',')
375 self._analysis_conf.cpu_list = [int(cpu) for cpu in
376 self._analysis_conf.cpu_list]
b6d9132b
AB
377
378 # convert min/max args from µs to ns, if needed
379 if hasattr(args, 'min') and args.min is not None:
380 args.min *= 1000
381 self._analysis_conf.min_duration = args.min
382 if hasattr(args, 'max') and args.max is not None:
383 args.max *= 1000
384 self._analysis_conf.max_duration = args.max
385
386 if hasattr(args, 'procname'):
47ba125c 387 if args.procname:
43b66dd6 388 self._analysis_conf.proc_list = args.procname.split(',')
28ad5ec8 389
43b66dd6
AB
390 if hasattr(args, 'tid'):
391 if args.tid:
392 self._analysis_conf.tid_list = args.tid.split(',')
393 self._analysis_conf.tid_list = [int(tid) for tid in
394 self._analysis_conf.tid_list]
f89605f0 395
1a68e04c
AB
396 if hasattr(args, 'freq'):
397 args.uniform_min = None
398 args.uniform_max = None
399 args.uniform_step = None
400
dbbdd963
PP
401 if args.freq_series:
402 # implies uniform buckets
403 args.freq_uniform = True
404
a0acc08c 405 if self._mi_mode:
1ab6b93a
PP
406 # print MI version if required
407 if args.mi_version:
408 print(mi.get_version_string())
409 sys.exit(0)
410
a0acc08c
PP
411 # print MI metadata if required
412 if args.metadata:
413 self._mi_print_metadata()
414 sys.exit(0)
415
416 # validate path argument (required at this point)
417 if not args.path:
418 self._cmdline_error('Please specify a trace path')
419
420 if type(args.path) is list:
421 args.path = args.path[0]
422
b6d9132b
AB
423 def _validate_transform_args(self, args):
424 pass
f89605f0 425
323b3fd6
PP
426 def _parse_args(self):
427 ap = argparse.ArgumentParser(description=self._DESC)
428
429 # common arguments
83ad157b
AB
430 ap.add_argument('-r', '--refresh', type=str,
431 help='Refresh period, with optional units suffix '
432 '(default units: s)')
a0acc08c
PP
433 ap.add_argument('--gmt', action='store_true',
434 help='Manipulate timestamps based on GMT instead '
435 'of local time')
73b71522 436 ap.add_argument('--skip-validation', action='store_true',
d3014022 437 help='Skip the trace validation')
bd3cd7c5
JD
438 ap.add_argument('--begin', type=str, help='start time: '
439 'hh:mm:ss[.nnnnnnnnn]')
440 ap.add_argument('--end', type=str, help='end time: '
441 'hh:mm:ss[.nnnnnnnnn]')
43a3c04c
AB
442 ap.add_argument('--period-begin', type=str,
443 help='Analysis period start marker event name')
444 ap.add_argument('--period-end', type=str,
445 help='Analysis period end marker event name '
446 '(requires --period-begin)')
05684c5e 447 ap.add_argument('--period-begin-key', type=str, default='cpu_id',
b9f05f8d
AB
448 help='Optional, list of event field names used to '
449 'match period markers (default: cpu_id)')
05684c5e
AB
450 ap.add_argument('--period-end-key', type=str,
451 help='Optional, list of event field names used to '
452 'match period marker. If none specified, use the same '
453 ' --period-begin-key')
454 ap.add_argument('--period-key-value', type=str,
455 help='Optional, define a fixed key value to which a'
456 ' period must correspond to be considered.')
a621ba35
AB
457 ap.add_argument('--cpu', type=str,
458 help='Filter the results only for this list of '
459 'CPU IDs')
a0acc08c
PP
460 ap.add_argument('--timerange', type=str, help='time range: '
461 '[begin,end]')
dd2efe70
PP
462 ap.add_argument('--progress-use-size', action='store_true',
463 help='use trace size to approximate progress')
323b3fd6 464 ap.add_argument('-V', '--version', action='version',
9463174b 465 version='LTTng Analyses v{}'.format(self._VERSION))
323b3fd6 466
a0acc08c
PP
467 # MI mode-dependent arguments
468 if self._mi_mode:
1ab6b93a
PP
469 ap.add_argument('--mi-version', action='store_true',
470 help='Print MI version')
a0acc08c 471 ap.add_argument('--metadata', action='store_true',
1ab6b93a 472 help='Print analysis\' metadata')
ee39b192
PP
473 ap.add_argument('--test-compatibility', action='store_true',
474 help='Check if the provided trace is supported and exit')
b9f05f8d
AB
475 ap.add_argument('path', metavar='<path/to/trace>',
476 help='trace path', nargs='*')
73f9d005
PP
477 ap.add_argument('--output-progress', action='store_true',
478 help='Print progress indication lines')
a0acc08c
PP
479 else:
480 ap.add_argument('--no-progress', action='store_true',
481 help='Don\'t display the progress bar')
b9f05f8d
AB
482 ap.add_argument('path', metavar='<path/to/trace>',
483 help='trace path')
a0acc08c 484
b6d9132b
AB
485 # Used to add command-specific args
486 self._add_arguments(ap)
323b3fd6 487
b6d9132b 488 args = ap.parse_args()
dd2efe70
PP
489
490 if self._mi_mode:
491 args.no_progress = True
492
493 if args.output_progress:
494 args.no_progress = False
495
bd3cd7c5 496 self._validate_transform_common_args(args)
b6d9132b 497 self._validate_transform_args(args)
323b3fd6
PP
498 self._args = args
499
b6d9132b
AB
500 @staticmethod
501 def _add_proc_filter_args(ap):
502 ap.add_argument('--procname', type=str,
503 help='Filter the results only for this list of '
504 'process names')
43b66dd6
AB
505 ap.add_argument('--tid', type=str,
506 help='Filter the results only for this list of TIDs')
b6d9132b
AB
507
508 @staticmethod
509 def _add_min_max_args(ap):
510 ap.add_argument('--min', type=float,
511 help='Filter out durations shorter than min usec')
512 ap.add_argument('--max', type=float,
513 help='Filter out durations longer than max usec')
514
515 @staticmethod
516 def _add_freq_args(ap, help=None):
517 if not help:
518 help = 'Output the frequency distribution'
519
520 ap.add_argument('--freq', action='store_true', help=help)
521 ap.add_argument('--freq-resolution', type=int, default=20,
522 help='Frequency distribution resolution '
523 '(default 20)')
1a68e04c
AB
524 ap.add_argument('--freq-uniform', action='store_true',
525 help='Use a uniform resolution across distributions')
86ea0394 526 ap.add_argument('--freq-series', action='store_true',
650e7f57
AB
527 help='Consolidate frequency distribution histogram '
528 'as a single one')
b6d9132b
AB
529
530 @staticmethod
531 def _add_log_args(ap, help=None):
532 if not help:
533 help = 'Output the events in chronological order'
534
535 ap.add_argument('--log', action='store_true', help=help)
536
b9f05f8d
AB
537 @staticmethod
538 def _add_top_args(ap, help=None):
539 if not help:
540 help = 'Output the top results'
541
542 ap.add_argument('--limit', type=int, default=10,
543 help='Limit to top X (default = 10)')
544 ap.add_argument('--top', action='store_true', help=help)
545
b6d9132b
AB
546 @staticmethod
547 def _add_stats_args(ap, help=None):
548 if not help:
549 help = 'Output statistics'
550
551 ap.add_argument('--stats', action='store_true', help=help)
552
553 def _add_arguments(self, ap):
554 pass
555
652bc6b7 556 def _process_date_args(self):
9079847d
AB
557 def parse_date(date):
558 try:
559 ts = parse_utils.parse_trace_collection_date(
560 self._traces, date, self._args.gmt
561 )
562 except ValueError as e:
563 self._cmdline_error(str(e))
b6d9132b
AB
564
565 return ts
566
9079847d
AB
567 self._args.multi_day = trace_utils.is_multi_day_trace_collection(
568 self._traces
569 )
602ac199
PP
570 begin_ts = None
571 end_ts = None
572
573 if self._args.timerange:
9079847d
AB
574 try:
575 begin_ts, end_ts = (
576 parse_utils.parse_trace_collection_time_range(
577 self._traces, self._args.timerange, self._args.gmt
578 )
579 )
580 except ValueError as e:
581 self._cmdline_error(str(e))
652bc6b7 582 else:
b6d9132b 583 if self._args.begin:
9079847d 584 begin_ts = parse_date(self._args.begin)
b6d9132b 585 if self._args.end:
9079847d 586 end_ts = parse_date(self._args.end)
652bc6b7 587
93c7af7d
AB
588 # We have to check if timestamp_begin is None, which
589 # it always is in older versions of babeltrace. In
590 # that case, the test is simply skipped and an invalid
591 # --end value will cause an empty analysis
dd2efe70
PP
592 if self._ts_begin is not None and \
593 end_ts < self._ts_begin:
b6d9132b
AB
594 self._cmdline_error(
595 '--end timestamp before beginning of trace')
596
602ac199
PP
597 self._analysis_conf.begin_ts = begin_ts
598 self._analysis_conf.end_ts = end_ts
b6d9132b
AB
599
600 def _create_analysis(self):
601 notification_cbs = {
a0acc08c 602 analysis.Analysis.TICK_CB: self._analysis_tick_cb
b6d9132b
AB
603 }
604
605 self._analysis = self._ANALYSIS_CLASS(self.state, self._analysis_conf)
606 self._analysis.register_notification_cbs(notification_cbs)
93c7af7d 607
323b3fd6 608 def _create_automaton(self):
56936af2 609 self._automaton = automaton.Automaton()
6e01ed18 610 self.state = self._automaton.state
bfb81992 611
a0acc08c 612 def _analysis_tick_cb(self, **kwargs):
b6d9132b
AB
613 begin_ns = kwargs['begin_ns']
614 end_ns = kwargs['end_ns']
615
a0acc08c
PP
616 self._analysis_tick(begin_ns, end_ns)
617 self._ticks += 1
b6d9132b 618
a0acc08c 619 def _analysis_tick(self, begin_ns, end_ns):
b6d9132b 620 raise NotImplementedError()
This page took 0.056111 seconds and 5 git commands to generate.