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