Completed
Push — master ( 2ae69e...39840a )
by Vincent
01:14
created

_Execution._submit_or_cancel()   C

Complexity

Conditions 8

Size

Total Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 15
rs 6.6666
cc 8
1
# coding: utf8
2
3
# Copyright 2013-2015 Vincent Jacques <[email protected]>
4
5
from __future__ import division, absolute_import, print_function
6
7
import datetime
8
import multiprocessing
9
import os.path
10
import pickle
11
12
import graphviz
13
import matplotlib
14
import matplotlib.dates
15
import matplotlib.figure
16
import matplotlib.backends.backend_agg
17
import wurlitzer
18
19
20
class Hooks(object):
21
    def action_pending(self, time, action):
22
        pass
23
24
    def action_ready(self, time, action):
25
        pass
26
27
    def action_canceled(self, time, action):
28
        pass
29
30
    def action_started(self, time, action):
31
        pass
32
33
    def action_printed(self, time, action, text):
34
        pass
35
36
    def action_successful(self, time, action):
37
        pass
38
39
    def action_failed(self, time, action):
40
        pass
41
42
43
def execute(action, jobs=1, keep_going=False, do_raise=True, hooks=Hooks()):
44
    """
45
    Recursively execute an action's dependencies then the action.
46
47
    :param Action action: the action to execute.
48
    :param int jobs: number of actions to execute in parallel. Pass ``None`` to let ActionTree choose.
49
    :param bool keep_going: if ``True``, then execution does not stop on first failure,
50
        but executes as many dependencies as possible.
51
    :param bool do_raise: if ``False``, then exceptions are caught and put in the :class:`.ExecutionReport`.
52
    :param hooks: @todo Document
53
54
    :raises CompoundException: when ``do_raise`` is ``True`` and dependencies raise exceptions.
55
56
    :rtype: ExecutionReport
57
    """
58
    _check_picklability(action)
59
    if jobs is None:
60
        jobs = multiprocessing.cpu_count()
61
    tasks = multiprocessing.JoinableQueue()
62
    events = multiprocessing.Queue()
63
    workers = [_Worker(tasks, events) for _ in xrange(jobs)]
64
    for worker in workers:
65
        worker.start()
66
    execution = _Execution(tasks, events, jobs, keep_going, do_raise, hooks, action)
67
    try:
68
        return execution.run()
69
    finally:
70
        for i in xrange(jobs):
71
            tasks.put(None)
72
        tasks.join()
73
        for worker in workers:
74
            worker.join()
75
76
77
def _check_picklability(stuff):
78
    # This is a way to fail fast if we see a non-picklable object
79
    # because ProcessPoolExecutor freezes forever if we try to transfer
80
    # a non-picklable object through its queues
81
    pickle.loads(pickle.dumps(stuff))
82
83
84
class ExecutionReport(object):
85
    """
86
    ExecutionReport()
87
88
    Execution report, returned by :func:`.execute`.
89
    """
90
91
    class ActionStatus(object):
92
        """
93
        ActionStatus()
94
95
        Status of a single :class:`.Action`.
96
        """
97
98
        Successful = "Successful"
99
        "The :attr:`status` after a successful execution."
100
        Canceled = "Canceled"
101
        "The :attr:`status` after a failed execution where a dependency raised an exception."
102
        Failed = "Failed"
103
        "The :attr:`status` after a failed execution where this action raised an exception."
104
105
        def __init__(self):
106
            self.__ready_time = None
107
            self.__cancel_time = None
108
            self.__start_time = None
109
            self.__success_time = None
110
            self.__return_value = None
111
            self.__failure_time = None
112
            self.__exception = None
113
            self.__output = None
114
115
        def _set_ready_time(self, ready_time):
116
            self.__ready_time = ready_time
117
118
        def _set_cancel_time(self, cancel_time):
119
            self.__cancel_time = cancel_time
120
121
        def _set_start_time(self, start_time):
122
            self.__start_time = start_time
123
124
        def _set_success(self, success_time, return_value):
125
            self.__success_time = success_time
126
            self.__return_value = return_value
127
            self._add_output(b"")
128
129
        def _set_failure(self, failure_time, exception):
130
            self.__failure_time = failure_time
131
            self.__exception = exception
132
            self._add_output(b"")
133
134
        def _add_output(self, output):
135
            self.__output = (self.__output or b"") + output
136
137
        @property
138
        def status(self):
139
            """
140
            @todo Document
141
            """
142
            if self.start_time:
143
                if self.success_time:
144
                    return self.Successful
145
                else:
146
                    assert self.failure_time
147
                    return self.Failed
148
            else:
149
                assert self.cancel_time
150
                return self.Canceled
151
152
        @property
153
        def ready_time(self):
154
            """
155
            The local :class:`~datetime.datetime` when this action was ready to execute.
156
            """
157
            return self.__ready_time
158
159
        @property
160
        def cancel_time(self):
161
            """
162
            The local :class:`~datetime.datetime` when this action was canceled.
163
            """
164
            return self.__cancel_time
165
166
        @property
167
        def start_time(self):
168
            """
169
            The local :class:`~datetime.datetime` at the begining of the execution of this action.
170
            """
171
            return self.__start_time
172
173
        @property
174
        def success_time(self):
175
            """
176
            The local :class:`~datetime.datetime` at the successful end of the execution of this action.
177
            """
178
            return self.__success_time
179
180
        @property
181
        def return_value(self):
182
            """
183
            @todo Document
184
            """
185
            return self.__return_value
186
187
        @property
188
        def failure_time(self):
189
            """
190
            The local :class:`~datetime.datetime` at the successful end of the execution of this action.
191
            """
192
            return self.__failure_time
193
194
        @property
195
        def exception(self):
196
            """
197
            @todo Document
198
            """
199
            return self.__exception
200
201
        @property
202
        def output(self):
203
            """
204
            @todo Document
205
            """
206
            return self.__output
207
208
    def __init__(self, actions):
209
        self.__action_statuses = {action: self.ActionStatus() for action in actions}
210
211
    @property
212
    def is_success(self):
213
        """
214
        ``True`` if the execution finished without error.
215
216
        :rtype: bool
217
        """
218
        return all(
219
            action_status.status == self.ActionStatus.Successful
220
            for action_status in self.__action_statuses.itervalues()
221
        )
222
223
    def get_action_status(self, action):
224
        """
225
        @todo Document
226
        """
227
        return self.__action_statuses[action]
228
229
    def get_actions_and_statuses(self):
230
        """
231
        @todo Document
232
        """
233
        return self.__action_statuses.items()
234
235
236
class _Worker(multiprocessing.Process):
237
    def __init__(self, tasks, events):
238
        multiprocessing.Process.__init__(self)
239
        self.tasks = tasks
240
        self.events = events
241
242
    def run(self):
243
        go_on = True
244
        while go_on:
245
            action = self.tasks.get()
246
            if action is None:
247
                go_on = False
248
            else:
249
                (action_id, action) = action
250
                self.execute_action(action_id, action)
251
            self.tasks.task_done()
252
253
    def execute_action(self, action_id, action):
254
        def handle(data):
255
            self.events.put(_PrintedEvent(action_id, datetime.datetime.now(), data))
256
        # This is a highly contrived use of Wurlitzer:
257
        # We just need to *capture* standards streams, so we trick Wurlitzer,
258
        # passing True instead of writeable file-like objects, and we redefine
259
        # _handle_xxx methods to intercept what it would write
260
        w = wurlitzer.Wurlitzer(stdout=True, stderr=True)
261
        w._handle_stdout = handle
262
        w._handle_stderr = handle
263
        with w:
264
            return_value = exception = None
265
            try:
266
                self.events.put(_StartedEvent(action_id, datetime.datetime.now()))
267
                return_value = action.do_execute()
268
            except Exception as e:
269
                exception = e
270
        try:
271
            _check_picklability((exception, return_value))
272
        except:
273
            self.events.put(_PicklingExceptionEvent(action_id))
274
        else:
275
            end_time = datetime.datetime.now()
276
            if exception:
277
                self.events.put(_FailedEvent(action_id, end_time, exception))
278
            else:
279
                self.events.put(_SuccessedEvent(action_id, end_time, return_value))
280
281
282
class _Event(object):
283
    def __init__(self, action_id):
284
        self.action_id = action_id
285
286
287
class _StartedEvent(_Event):
288
    def __init__(self, action_id, start_time):
289
        super(_StartedEvent, self).__init__(action_id)
290
        self.start_time = start_time
291
292
    def apply(self, execution, action):
293
        execution.report.get_action_status(action)._set_start_time(self.start_time)
294
        execution.hooks.action_started(self.start_time, action)
295
296
297
class _SuccessedEvent(_Event):
298
    def __init__(self, action_id, success_time, return_value):
299
        super(_SuccessedEvent, self).__init__(action_id)
300
        self.success_time = success_time
301
        self.return_value = return_value
302
303
    def apply(self, execution, action):
304
        execution.submitted.remove(action)
305
        execution.successful.add(action)
306
        execution.report.get_action_status(action)._set_success(self.success_time, self.return_value)
307
        execution.hooks.action_successful(self.success_time, action)
308
309
310
class _PrintedEvent(_Event):
311
    def __init__(self, action_id, print_time, text):
312
        super(_PrintedEvent, self).__init__(action_id)
313
        self.print_time = print_time
314
        self.text = text
315
316
    def apply(self, execution, action):
317
        execution.report.get_action_status(action)._add_output(self.text)
318
        execution.hooks.action_printed(self.print_time, action, self.text)
319
320
321
class _FailedEvent(_Event):
322
    def __init__(self, action_id, failure_time, exception):
323
        super(_FailedEvent, self).__init__(action_id)
324
        self.failure_time = failure_time
325
        self.exception = exception
326
327
    def apply(self, execution, action):
328
        execution.submitted.remove(action)
329
        execution.failed.add(action)
330
        execution.exceptions.append(self.exception)
331
        execution.report.get_action_status(action)._set_failure(self.failure_time, self.exception)
332
        execution.hooks.action_failed(self.failure_time, action)
333
334
335
class _PicklingExceptionEvent(_Event):
336
    def apply(self, execution, action):
337
        raise pickle.PicklingError()
338
339
340
class _Execution(object):
341
    def __init__(self, tasks, events, jobs, keep_going, do_raise, hooks, action):
342
        self.tasks = tasks
343
        self.events = events
344
        self.jobs = jobs
345
        self.keep_going = keep_going
346
        self.do_raise = do_raise
347
        self.hooks = hooks
348
        self.actions_by_id = {id(action): action for action in action.get_all_dependencies()}
349
        self.pending = set(self.actions_by_id.itervalues())
350
        self.submitted = set()
351
        self.successful = set()
352
        self.failed = set()
353
        self.exceptions = []
354
        self.report = ExecutionReport(self.pending)
355
356
    def run(self):
357
        now = datetime.datetime.now()
358
        for action in self.pending:
359
            self.hooks.action_pending(now, action)
360
        while self.pending or self.submitted:
361
            self._progress()
362
363
        if self.do_raise and self.exceptions:
364
            raise CompoundException(self.exceptions, self.report)
365
        else:
366
            return self.report
367
368
    def _progress(self):
369
        now = datetime.datetime.now()
370
        if self.keep_going or not self.exceptions:
371
            self._submit_or_cancel(now)
372
        else:
373
            self._cancel(now)
374
        self._wait()
375
376
    def _submit_or_cancel(self, now):
377
        go_on = True
378
        while go_on:
379
            go_on = False
380
            for action in set(self.pending):
381
                done = self.successful | self.failed
382
                if all(d in done for d in action.dependencies):
383
                    if any(d in self.failed for d in action.dependencies):
384
                        self._mark_action_canceled(action, cancel_time=now)
385
                        self.pending.remove(action)
386
                        go_on = True
387
                    else:
388
                        self.report.get_action_status(action)._set_ready_time(now)
389
                        if len(self.submitted) <= self.jobs:
390
                            self._submit_action(action, ready_time=now)
391
392
    def _cancel(self, now):
393
        for action in self.pending:
394
            self._mark_action_canceled(action, cancel_time=now)
395
        self.pending.clear()
396
397
    def _wait(self):
398
        if self.submitted:
399
            event = self.events.get()
400
            event.apply(self, self.actions_by_id[event.action_id])
401
402
    def _submit_action(self, action, ready_time):
403
        self.tasks.put((id(action), action))
404
        self.submitted.add(action)
405
        self.pending.remove(action)
406
        self.hooks.action_ready(ready_time, action)
407
408
    def _mark_action_canceled(self, action, cancel_time):
409
        self.failed.add(action)
410
        self.report.get_action_status(action)._set_cancel_time(cancel_time)
411
        self.hooks.action_canceled(cancel_time, action)
412
413
414
class Action(object):
415
    """
416
    The main class of ActionTree.
417
    An action to be started after all its dependencies are finished.
418
    Pass it to :func:`.execute`.
419
420
    This is a base class for your custom actions.
421
    You must define a ``def do_execute(self):`` method that performs the action.
422
    Its return value is ignored.
423
    If it raises and exception, it is captured and re-raised in a :exc:`CompoundException`.
424
425
    """
426
    # @todo Add a note about printing anything in do_execute
427
    # @todo Add a note saying that outputs, return values and exceptions are captured
428
    # @todo Add a note saying that output channels MUST be flushed before returning
429
    # @todo Add a note saying that the class, the return value and any exceptions raised MUST be picklable
430
431
    def __init__(self, label):
432
        """
433
        :param label: whatever you want to attach to the action.
434
            Can be retrieved by :attr:`label` and :meth:`get_preview`.
435
        """
436
        self.__label = label
437
        self.__dependencies = set()
438
439
    @property
440
    def label(self):
441
        """
442
        The label passed to the constructor.
443
        """
444
        return self.__label
445
446
    def add_dependency(self, dependency):
447
        """
448
        Add a dependency to be executed before this action.
449
        Order of insertion of dependencies is not important.
450
451
        :param Action dependency:
452
453
        :raises DependencyCycleException: when adding the new dependency would create a cycle.
454
        """
455
        if self in dependency.get_all_dependencies():
456
            raise DependencyCycleException()
457
        self.__dependencies.add(dependency)
458
459
    @property
460
    def dependencies(self):
461
        """
462
        Return the list of this action's dependencies.
463 View Code Duplication
        """
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
464
        return list(self.__dependencies)
465
466
    def get_all_dependencies(self):
467
        """
468
        Return the set of this action's recursive dependencies, including itself.
469
        """
470
        dependencies = set([self])
471
        for dependency in self.__dependencies:
472
            dependencies |= dependency.get_all_dependencies()
473
        return dependencies
474
475
    def get_preview(self):
476
        """
477
        Return the labels of this action and its dependencies, in an order that could be the execution order.
478
        """
479
        return [action.__label for action in self.get_possible_execution_order()]
480
481
    def get_possible_execution_order(self, seen_actions=None):
482
        if seen_actions is None:
483
            seen_actions = set()
484
        actions = []
485
        if self not in seen_actions:
486
            seen_actions.add(self)
487
            for dependency in self.__dependencies:
488
                actions += dependency.get_possible_execution_order(seen_actions)
489
            actions.append(self)
490 View Code Duplication
        return actions
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
491
492
493
class CompoundException(Exception):
494
    """
495
    Exception thrown by :func:`.execute` when a dependencies raise exceptions.
496
    """
497
498
    def __init__(self, exceptions, execution_report):
499
        super(CompoundException, self).__init__(exceptions)
500
        self.__exceptions = exceptions
501
        self.__execution_report = execution_report
502
503
    @property
504
    def exceptions(self):
505
        """
506
        The list of the encapsulated exceptions.
507
        """
508
        return self.__exceptions
509
510
    @property
511
    def execution_report(self):
512
        """
513
        The :class:`.ExecutionReport` of the failed execution.
514
        """
515
        return self.__execution_report
516
517
518
class DependencyCycleException(Exception):
519
    """
520
    Exception thrown by :meth:`.Action.add_dependency` when adding the new dependency would create a cycle.
521
    """
522
523
    def __init__(self):
524
        super(DependencyCycleException, self).__init__("Dependency cycle")
525
526
527
class DependencyGraph(object):
528
    """
529
    @todo Document
530
    """
531
532
    def __init__(self, action):
533
        self.__graphviz_graph = graphviz.Digraph("action", node_attr={"shape": "box"})
534
        nodes = {}
535
        for (i, action) in enumerate(action.get_possible_execution_order()):
536
            node = str(i)
537
            nodes[action] = node
538
            self.__graphviz_graph.node(node, str(action.label))
539
            for dependency in action.dependencies:
540
                assert dependency in nodes  # Because we are iterating a possible execution order
541
                self.__graphviz_graph.edge(node, nodes[dependency])
542
543
    def write_to_png(self, filename):  # pragma no cover (Untestable? But small.)
544
        """
545
        Write the graph as a PNG image to the specified file.
546
547
        See also :meth:`get_graphviz_graph` if you want to draw the graph somewhere else.
548
        """
549
        directory = os.path.dirname(filename)
550
        filename = os.path.basename(filename)
551
        filename, ext = os.path.splitext(filename)
552
        g = self.get_graphviz_graph()
553
        g.format = "png"
554
        g.render(directory=directory, filename=filename, cleanup=True)
555
556
    def get_graphviz_graph(self):
557
        """
558
        Return a :class:`graphviz.Digraph` of this dependency graph.
559
560
        See also :meth:`write_to_png` for the simplest use-case.
561
        """
562
        return self.__graphviz_graph.copy()
563
564
565
class GanttChart(object):  # pragma no cover (Too difficult to unit test)
566
    """
567
    @todo Document
568
    """
569
570
    def __init__(self, report):
571
        # @todo Sort actions somehow to improve readability (top-left to bottom-right)
572
        self.__actions = {
573
            id(action): self.__make_action(action, status)
574
            for (action, status) in report.get_actions_and_statuses()
575
        }
576
577
    # @todo Factorize Actions
578
    class SuccessfulAction(object):
579
        def __init__(self, action, status):
580
            self.__label = str(action.label)
581
            self.__id = id(action)
582
            self.__dependencies = set(id(d) for d in action.dependencies)
583
            self.__ready_time = status.ready_time
584
            self.__start_time = status.start_time
585
            self.__success_time = status.success_time
586
587
        @property
588
        def min_time(self):
589
            return self.__ready_time
590
591
        @property
592
        def max_time(self):
593
            return self.__success_time
594
595 View Code Duplication
        def draw(self, ax, ordinates, actions):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
596
            ordinate = ordinates[self.__id]
597
            ax.plot([self.__ready_time, self.__start_time], [ordinate, ordinate], color="blue", lw=1)
598
            # @todo Use an other end-style to avoid pixels before/after min/max_time
599
            ax.plot([self.__start_time, self.__success_time], [ordinate, ordinate], color="blue", lw=4)
600
            # @todo Make sure the text is not outside the plot on the right
601
            ax.annotate(self.__label, xy=(self.__start_time, ordinate), xytext=(0, 3), textcoords="offset points")
602
            for d in self.__dependencies:
603
                ax.plot([actions[d].max_time, self.min_time], [ordinates[d], ordinate], "k:", lw=1)
604
605
    class FailedAction(object):
606
        def __init__(self, action, status):
607
            self.__label = str(action.label)
608
            self.__id = id(action)
609
            self.__dependencies = set(id(d) for d in action.dependencies)
610
            self.__ready_time = status.ready_time
611
            self.__start_time = status.start_time
612
            self.__failure_time = status.failure_time
613
614
        @property
615
        def min_time(self):
616
            return self.__ready_time
617
618
        @property
619
        def max_time(self):
620
            return self.__failure_time
621
622 View Code Duplication
        def draw(self, ax, ordinates, actions):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
623
            ordinate = ordinates[self.__id]
624
            ax.plot([self.__ready_time, self.__start_time], [ordinate, ordinate], color="red", lw=1)
625
            ax.plot([self.__start_time, self.__failure_time], [ordinate, ordinate], color="red", lw=4)
626
            ax.annotate(self.__label, xy=(self.__start_time, ordinate), xytext=(0, 3), textcoords="offset points")
627
            for d in self.__dependencies:
628
                ax.plot([actions[d].max_time, self.min_time], [ordinates[d], ordinate], "k:", lw=1)
629
630
    class CanceledAction(object):
631
        def __init__(self, action, status):
632
            self.__label = str(action.label)
633
            self.__id = id(action)
634
            self.__dependencies = set(id(d) for d in action.dependencies)
635
            self.__ready_time = status.ready_time
636
            self.__cancel_time = status.cancel_time
637
638
        @property
639
        def min_time(self):
640
            return self.__cancel_time if self.__ready_time is None else self.__ready_time
641
642
        @property
643
        def max_time(self):
644
            return self.__cancel_time
645
646
        def draw(self, ax, ordinates, actions):
647
            ordinate = ordinates[self.__id]
648
            if self.__ready_time:
649
                ax.plot([self.__ready_time, self.__cancel_time], [ordinate, ordinate], color="grey", lw=1)
650
            ax.annotate(
651
                "(Canceled) {}".format(self.__label),
652
                xy=(self.__cancel_time, ordinate),
653
                xytext=(0, 3),
654
                textcoords="offset points"
655
            )
656
            for d in self.__dependencies:
657
                ax.plot([actions[d].max_time, self.min_time], [ordinates[d], ordinate], "k:", lw=1)
658
659
    @classmethod
660
    def __make_action(cls, action, status):
661
        if status.status == ExecutionReport.ActionStatus.Successful:
662
            return cls.SuccessfulAction(action, status)
663
        elif status.status == ExecutionReport.ActionStatus.Failed:
664
            return cls.FailedAction(action, status)
665
        elif status.status == ExecutionReport.ActionStatus.Canceled:
666
            return cls.CanceledAction(action, status)
667
668
    def write_to_png(self, filename):
669
        """
670
        Write the Gantt chart as a PNG image to the specified file.
671
672
        See also :meth:`get_mpl_figure` and :meth:`plot_on_mpl_axes` if you want to draw the report somewhere else.
673
        """
674
        figure = self.get_mpl_figure()
675
        canvas = matplotlib.backends.backend_agg.FigureCanvasAgg(figure)
676
        canvas.print_figure(filename)
677
678
    def get_mpl_figure(self):
679
        """
680
        Return a :class:`matplotlib.figure.Figure` of this Gantt chart.
681
682
        See also :meth:`plot_on_mpl_axes` if you want to draw the Gantt chart on your own matplotlib figure.
683
684
        See also :meth:`write_to_png` for the simplest use-case.
685
        """
686
        fig = matplotlib.figure.Figure()
687
        ax = fig.add_subplot(1, 1, 1)
688
689
        self.plot_on_mpl_axes(ax)
690
691
        return fig
692
693
    @staticmethod
694
    def __nearest(v, values):
695
        for i, value in enumerate(values):
696
            if v < value:
697
                break
698
        if i == 0:
699
            return values[0]
700
        else:
701
            if v - values[i - 1] <= values[i] - v:
702
                return values[i - 1]
703
            else:
704
                return values[i]
705
706
    __intervals = [
707
        1, 2, 5, 10, 15, 30, 60,
708
        2 * 60, 10 * 60, 30 * 60, 3600,
709
        2 * 3600, 3 * 3600, 6 * 3600, 12 * 3600, 24 * 3600,
710
    ]
711
712
    def plot_on_mpl_axes(self, ax):
713
        """
714
        Plot this Gantt chart on the provided :class:`matplotlib.axes.Axes`.
715
716
        See also :meth:`write_to_png` and :meth:`get_mpl_figure` for the simpler use-cases.
717
        """
718
        ordinates = {ident: len(self.__actions) - i for (i, ident) in enumerate(self.__actions.iterkeys())}
719
720
        for action in self.__actions.itervalues():
721
            action.draw(ax, ordinates, self.__actions)
722
723
        ax.get_yaxis().set_ticklabels([])
724
        ax.set_ylim(0.5, len(self.__actions) + 1)
725
726
        min_time = min(a.min_time for a in self.__actions.itervalues()).replace(microsecond=0)
727
        max_time = (
728
            max(a.max_time for a in self.__actions.itervalues()).replace(microsecond=0) +
729
            datetime.timedelta(seconds=1)
730
        )
731
        duration = int((max_time - min_time).total_seconds())
732
733
        ax.set_xlabel("Local time")
734
        ax.set_xlim(min_time, max_time)
735
        ax.xaxis_date()
736
        ax.xaxis.set_major_formatter(matplotlib.dates.DateFormatter("%H:%M:%S"))
737
        ax.xaxis.set_major_locator(matplotlib.dates.AutoDateLocator(maxticks=4, minticks=5))
738
739
        ax2 = ax.twiny()
740
        ax2.set_xlabel("Relative time")
741
        ax2.set_xlim(min_time, max_time)
742
        ticks = range(0, duration, self.__nearest(duration // 5, self.__intervals))
743
        ax2.xaxis.set_ticks([min_time + datetime.timedelta(seconds=s) for s in ticks])
744
        ax2.xaxis.set_ticklabels(ticks)
745