1#
2#  Copyright (C) 2018 Codethink Limited
3#
4#  This program is free software; you can redistribute it and/or
5#  modify it under the terms of the GNU Lesser General Public
6#  License as published by the Free Software Foundation; either
7#  version 2 of the License, or (at your option) any later version.
8#
9#  This library is distributed in the hope that it will be useful,
10#  but WITHOUT ANY WARRANTY; without even the implied warranty of
11#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.	 See the GNU
12#  Lesser General Public License for more details.
13#
14#  You should have received a copy of the GNU Lesser General Public
15#  License along with this library. If not, see <http://www.gnu.org/licenses/>.
16#
17#  Authors:
18#        Tristan Van Berkom <tristan.vanberkom@codethink.co.uk>
19import os
20import sys
21import click
22import curses
23
24# Import a widget internal for formatting time codes
25from .widget import TimeCode
26from .._scheduler import ElementJob
27
28
29# Status()
30#
31# A widget for formatting overall status.
32#
33# Note that the render() and clear() methods in this class are
34# simply noops in the case that the application is not connected
35# to a terminal, or if the terminal does not support ANSI escape codes.
36#
37# Args:
38#    context (Context): The Context
39#    content_profile (Profile): Formatting profile for content text
40#    format_profile (Profile): Formatting profile for formatting text
41#    success_profile (Profile): Formatting profile for success text
42#    error_profile (Profile): Formatting profile for error text
43#    stream (Stream): The Stream
44#    colors (bool): Whether to print the ANSI color codes in the output
45#
46class Status():
47
48    # Table of the terminal capabilities we require and use
49    _TERM_CAPABILITIES = {
50        'move_up': 'cuu1',
51        'move_x': 'hpa',
52        'clear_eol': 'el'
53    }
54
55    def __init__(self, context,
56                 content_profile, format_profile,
57                 success_profile, error_profile,
58                 stream, colors=False):
59
60        self._context = context
61        self._content_profile = content_profile
62        self._format_profile = format_profile
63        self._success_profile = success_profile
64        self._error_profile = error_profile
65        self._stream = stream
66        self._jobs = []
67        self._last_lines = 0  # Number of status lines we last printed to console
68        self._spacing = 1
69        self._colors = colors
70        self._header = _StatusHeader(context,
71                                     content_profile, format_profile,
72                                     success_profile, error_profile,
73                                     stream)
74
75        self._term_width, _ = click.get_terminal_size()
76        self._alloc_lines = 0
77        self._alloc_columns = None
78        self._line_length = 0
79        self._need_alloc = True
80        self._term_caps = self._init_terminal()
81
82    # add_job()
83    #
84    # Adds a job to track in the status area
85    #
86    # Args:
87    #    element (Element): The element of the job to track
88    #    action_name (str): The action name for this job
89    #
90    def add_job(self, job):
91        elapsed = self._stream.elapsed_time
92        job = _StatusJob(self._context, job, self._content_profile, self._format_profile, elapsed)
93        self._jobs.append(job)
94        self._need_alloc = True
95
96    # remove_job()
97    #
98    # Removes a job currently being tracked in the status area
99    #
100    # Args:
101    #    element (Element): The element of the job to track
102    #    action_name (str): The action name for this job
103    #
104    def remove_job(self, job):
105        action_name = job.action_name
106        if not isinstance(job, ElementJob):
107            element = None
108        else:
109            element = job.element
110
111        self._jobs = [
112            job for job in self._jobs
113            if not (job.element is element and
114                    job.action_name == action_name)
115        ]
116        self._need_alloc = True
117
118    # clear()
119    #
120    # Clear the status area, it is necessary to call
121    # this before printing anything to the console if
122    # a status area is in use.
123    #
124    # To print some logging to the output and then restore
125    # the status, use the following:
126    #
127    #   status.clear()
128    #   ... print something to console ...
129    #   status.render()
130    #
131    def clear(self):
132
133        if not self._term_caps:
134            return
135
136        for _ in range(self._last_lines):
137            self._move_up()
138            self._clear_line()
139        self._last_lines = 0
140
141    # render()
142    #
143    # Render the status area.
144    #
145    # If you are not printing a line in addition to rendering
146    # the status area, for instance in a timeout, then it is
147    # not necessary to call clear().
148    def render(self):
149
150        if not self._term_caps:
151            return
152
153        elapsed = self._stream.elapsed_time
154
155        self.clear()
156        self._check_term_width()
157        self._allocate()
158
159        # Nothing to render, early return
160        if self._alloc_lines == 0:
161            return
162
163        # Before rendering the actual lines, we need to add some line
164        # feeds for the amount of lines we intend to print first, and
165        # move cursor position back to the first line
166        for _ in range(self._alloc_lines + self._header.lines):
167            click.echo('', err=True)
168        for _ in range(self._alloc_lines + self._header.lines):
169            self._move_up()
170
171        # Render the one line header
172        text = self._header.render(self._term_width, elapsed)
173        click.echo(text, color=self._colors, err=True)
174
175        # Now we have the number of columns, and an allocation for
176        # alignment of each column
177        n_columns = len(self._alloc_columns)
178        for line in self._job_lines(n_columns):
179            text = ''
180            for job in line:
181                column = line.index(job)
182                text += job.render(self._alloc_columns[column] - job.size, elapsed)
183
184                # Add spacing between columns
185                if column < (n_columns - 1):
186                    text += ' ' * self._spacing
187
188            # Print the line
189            click.echo(text, color=self._colors, err=True)
190
191        # Track what we printed last, for the next clear
192        self._last_lines = self._alloc_lines + self._header.lines
193
194    ###################################################
195    #                 Private Methods                 #
196    ###################################################
197
198    # _init_terminal()
199    #
200    # Initialize the terminal and return the resolved terminal
201    # capabilities dictionary.
202    #
203    # Returns:
204    #    (dict|None): The resolved terminal capabilities dictionary,
205    #                 or None if the terminal does not support all
206    #                 of the required capabilities.
207    #
208    def _init_terminal(self):
209
210        # We need both output streams to be connected to a terminal
211        if not (sys.stdout.isatty() and sys.stderr.isatty()):
212            return None
213
214        # Initialized terminal, curses might decide it doesnt
215        # support this terminal
216        try:
217            curses.setupterm(os.environ.get('TERM', 'dumb'))
218        except curses.error:
219            return None
220
221        term_caps = {}
222
223        # Resolve the string capabilities we need for the capability
224        # names we need.
225        #
226        for capname, capval in self._TERM_CAPABILITIES.items():
227            code = curses.tigetstr(capval)
228
229            # If any of the required capabilities resolve empty strings or None,
230            # then we don't have the capabilities we need for a status bar on
231            # this terminal.
232            if not code:
233                return None
234
235            # Decode sequences as latin1, as they are always 8-bit bytes,
236            # so when b'\xff' is returned, this must be decoded to u'\xff'.
237            #
238            # This technique is employed by the python blessings library
239            # as well, and should provide better compatibility with most
240            # terminals.
241            #
242            term_caps[capname] = code.decode('latin1')
243
244        return term_caps
245
246    def _check_term_width(self):
247        term_width, _ = click.get_terminal_size()
248        if self._term_width != term_width:
249            self._term_width = term_width
250            self._need_alloc = True
251
252    def _move_up(self):
253        assert self._term_caps is not None
254
255        # Explicitly move to beginning of line, fixes things up
256        # when there was a ^C or ^Z printed to the terminal.
257        move_x = curses.tparm(self._term_caps['move_x'].encode('latin1'), 0)
258        move_x = move_x.decode('latin1')
259
260        move_up = curses.tparm(self._term_caps['move_up'].encode('latin1'))
261        move_up = move_up.decode('latin1')
262
263        click.echo(move_x + move_up, nl=False, err=True)
264
265    def _clear_line(self):
266        assert self._term_caps is not None
267
268        clear_eol = curses.tparm(self._term_caps['clear_eol'].encode('latin1'))
269        clear_eol = clear_eol.decode('latin1')
270        click.echo(clear_eol, nl=False, err=True)
271
272    def _allocate(self):
273        if not self._need_alloc:
274            return
275
276        # State when there is no jobs to display
277        alloc_lines = 0
278        alloc_columns = []
279        line_length = 0
280
281        # Test for the widest width which fits columnized jobs
282        for columns in reversed(range(len(self._jobs))):
283            alloc_lines, alloc_columns = self._allocate_columns(columns + 1)
284
285            # If the sum of column widths with spacing in between
286            # fits into the terminal width, this is a good allocation.
287            line_length = sum(alloc_columns) + (columns * self._spacing)
288            if line_length < self._term_width:
289                break
290
291        self._alloc_lines = alloc_lines
292        self._alloc_columns = alloc_columns
293        self._line_length = line_length
294        self._need_alloc = False
295
296    def _job_lines(self, columns):
297        for i in range(0, len(self._jobs), columns):
298            yield self._jobs[i:i + columns]
299
300    # Returns an array of integers representing the maximum
301    # length in characters for each column, given the current
302    # list of jobs to render.
303    #
304    def _allocate_columns(self, columns):
305        column_widths = [0 for _ in range(columns)]
306        lines = 0
307        for line in self._job_lines(columns):
308            line_len = len(line)
309            lines += 1
310            for col in range(columns):
311                if col < line_len:
312                    job = line[col]
313                    column_widths[col] = max(column_widths[col], job.size)
314
315        return lines, column_widths
316
317
318# _StatusHeader()
319#
320# A delegate object for rendering the header part of the Status() widget
321#
322# Args:
323#    context (Context): The Context
324#    content_profile (Profile): Formatting profile for content text
325#    format_profile (Profile): Formatting profile for formatting text
326#    success_profile (Profile): Formatting profile for success text
327#    error_profile (Profile): Formatting profile for error text
328#    stream (Stream): The Stream
329#
330class _StatusHeader():
331
332    def __init__(self, context,
333                 content_profile, format_profile,
334                 success_profile, error_profile,
335                 stream):
336
337        #
338        # Public members
339        #
340        self.lines = 3
341
342        #
343        # Private members
344        #
345        self._content_profile = content_profile
346        self._format_profile = format_profile
347        self._success_profile = success_profile
348        self._error_profile = error_profile
349        self._stream = stream
350        self._time_code = TimeCode(context, content_profile, format_profile)
351        self._context = context
352
353    def render(self, line_length, elapsed):
354        project = self._context.get_toplevel_project()
355        line_length = max(line_length, 80)
356
357        #
358        # Line 1: Session time, project name, session / total elements
359        #
360        #  ========= 00:00:00 project-name (143/387) =========
361        #
362        session = str(len(self._stream.session_elements))
363        total = str(len(self._stream.total_elements))
364
365        size = 0
366        text = ''
367        size += len(total) + len(session) + 4  # Size for (N/N) with a leading space
368        size += 8  # Size of time code
369        size += len(project.name) + 1
370        text += self._time_code.render_time(elapsed)
371        text += ' ' + self._content_profile.fmt(project.name)
372        text += ' ' + self._format_profile.fmt('(') + \
373                self._content_profile.fmt(session) + \
374                self._format_profile.fmt('/') + \
375                self._content_profile.fmt(total) + \
376                self._format_profile.fmt(')')
377
378        line1 = self._centered(text, size, line_length, '=')
379
380        #
381        # Line 2: Dynamic list of queue status reports
382        #
383        #  (Fetched:0 117 0)→ (Built:4 0 0)
384        #
385        size = 0
386        text = ''
387
388        # Format and calculate size for each queue progress
389        for queue in self._stream.queues:
390
391            # Add spacing
392            if self._stream.queues.index(queue) > 0:
393                size += 2
394                text += self._format_profile.fmt('→ ')
395
396            queue_text, queue_size = self._render_queue(queue)
397            size += queue_size
398            text += queue_text
399
400        line2 = self._centered(text, size, line_length, ' ')
401
402        #
403        # Line 3: Cache usage percentage report
404        #
405        #  ~~~~~~ cache: 69% ~~~~~~
406        #
407        usage = self._context.get_artifact_cache_usage()
408        usage_percent = '{}%'.format(usage.used_percent)
409
410        size = 21
411        size += len(usage_percent)
412        if usage.used_percent >= 95:
413            formatted_usage_percent = self._error_profile.fmt(usage_percent)
414        elif usage.used_percent >= 80:
415            formatted_usage_percent = self._content_profile.fmt(usage_percent)
416        else:
417            formatted_usage_percent = self._success_profile.fmt(usage_percent)
418
419        text = self._format_profile.fmt("~~~~~~ ") + \
420            self._content_profile.fmt('cache') + \
421            self._format_profile.fmt(': ') + \
422            formatted_usage_percent + \
423            self._format_profile.fmt(' ~~~~~~')
424        line3 = self._centered(text, size, line_length, ' ')
425
426        return line1 + '\n' + line2 + '\n' + line3
427
428    ###################################################
429    #                 Private Methods                 #
430    ###################################################
431    def _render_queue(self, queue):
432        processed = str(len(queue.processed_elements))
433        skipped = str(len(queue.skipped_elements))
434        failed = str(len(queue.failed_elements))
435
436        size = 5  # Space for the formatting '[', ':', ' ', ' ' and ']'
437        size += len(queue.complete_name)
438        size += len(processed) + len(skipped) + len(failed)
439        text = self._format_profile.fmt("(") + \
440            self._content_profile.fmt(queue.complete_name) + \
441            self._format_profile.fmt(":") + \
442            self._success_profile.fmt(processed) + ' ' + \
443            self._content_profile.fmt(skipped) + ' ' + \
444            self._error_profile.fmt(failed) + \
445            self._format_profile.fmt(")")
446
447        return (text, size)
448
449    def _centered(self, text, size, line_length, fill):
450        remaining = line_length - size
451        remaining -= 2
452
453        final_text = self._format_profile.fmt(fill * (remaining // 2)) + ' '
454        final_text += text
455        final_text += ' ' + self._format_profile.fmt(fill * (remaining // 2))
456
457        return final_text
458
459
460# _StatusJob()
461#
462# A delegate object for rendering a job in the status area
463#
464# Args:
465#    context (Context): The Context
466#    job (Job): The job being processed
467#    content_profile (Profile): Formatting profile for content text
468#    format_profile (Profile): Formatting profile for formatting text
469#    elapsed (datetime): The offset into the session when this job is created
470#
471class _StatusJob():
472
473    def __init__(self, context, job, content_profile, format_profile, elapsed):
474        action_name = job.action_name
475        if not isinstance(job, ElementJob):
476            element = None
477        else:
478            element = job.element
479
480        #
481        # Public members
482        #
483        self.element = element            # The Element
484        self.action_name = action_name    # The action name
485        self.size = None                  # The number of characters required to render
486        self.full_name = element._get_full_name() if element else action_name
487
488        #
489        # Private members
490        #
491        self._offset = elapsed
492        self._content_profile = content_profile
493        self._format_profile = format_profile
494        self._time_code = TimeCode(context, content_profile, format_profile)
495
496        # Calculate the size needed to display
497        self.size = 10  # Size of time code with brackets
498        self.size += len(action_name)
499        self.size += len(self.full_name)
500        self.size += 3  # '[' + ':' + ']'
501
502    # render()
503    #
504    # Render the Job, return a rendered string
505    #
506    # Args:
507    #    padding (int): Amount of padding to print in order to align with columns
508    #    elapsed (datetime): The session elapsed time offset
509    #
510    def render(self, padding, elapsed):
511        text = self._format_profile.fmt('[') + \
512            self._time_code.render_time(elapsed - self._offset) + \
513            self._format_profile.fmt(']')
514
515        # Add padding after the display name, before terminating ']'
516        name = self.full_name + (' ' * padding)
517        text += self._format_profile.fmt('[') + \
518            self._content_profile.fmt(self.action_name) + \
519            self._format_profile.fmt(':') + \
520            self._content_profile.fmt(name) + \
521            self._format_profile.fmt(']')
522
523        return text
524