potluck.html_tools
Tools for building HTML strings.
html_tools.py
1# -*- coding: utf-8 -*- 2""" 3Tools for building HTML strings. 4 5html_tools.py 6""" 7 8import sys 9import traceback 10import difflib 11import os 12import re 13import io 14import base64 15 16from . import phrasing 17from . import file_utils 18 19 20# Figure out how to escape HTML (2/3 compatibility) 21if sys.version_info[0] < 3: 22 # use cgi.escape instead of html.escape 23 import cgi 24 escape = cgi.escape 25else: 26 # use html.escape 27 import html 28 escape = html.escape 29 30 31#---------# 32# Globals # 33#---------# 34 35STATUS_SYMBOLS = { 36 "unknown": " ", # non-breaking space 37 "not applicable": "–", 38 "failed": "✗", 39 "partial": "~", 40 "accomplished": "✓", 41} 42""" 43The symbols used as shorthand icons for each goal accomplishment status. 44""" 45 46SHORT_REPR_LIMIT = 160 47""" 48Limit in terms of characters before we try advanced formatting. 49""" 50 51 52#-----------# 53# Functions # 54#-----------# 55 56def len_as_text(html_string): 57 """ 58 Returns an approximate length of the given string in characters 59 assuming HTML tags would not be visible and HTML entities would be 60 single characters. 61 """ 62 without_tags = re.sub("<[^>]*>", "", html_string) 63 without_entities = re.sub("&[#0-9A-Za-z]+;", ".", without_tags) 64 return len(without_entities) 65 66 67#---------------------------# 68# Text wrapping/indentation # 69#---------------------------# 70 71 72def wrapped_fragments(line, width=80, indent=''): 73 """ 74 Wraps a single line of text into one or more lines, yielding a 75 sequence of strings. Breaks on spaces/tabs only, and does not 76 attempt any kind of optimization but simply breaks greedily as close 77 to the given width as possible. The given indentation will be 78 prepended to each line, including the first. 79 """ 80 # Apply indentation 81 line = indent + line 82 83 if len(line) <= width: 84 yield line 85 return 86 87 # Look backwards from width for a place we can break at 88 breakpoint = None 89 for i in range(width, len(indent), -1): 90 # (note: breaking on first char doesn't make sense) 91 c = line[i] 92 if c in ' \t': 93 breakpoint = i 94 break 95 96 # If we didn't find a breakpoint that would respect the 97 # target width, find the first one we can 98 if breakpoint is None: 99 for i in range(width, len(line)): 100 c = line[i] 101 if c in ' \t': 102 breakpoint = i 103 104 if breakpoint is None: 105 # Can't find any breakpoint :'( 106 yield line 107 else: 108 first = line[:breakpoint] 109 rest = line[breakpoint + 1:] 110 yield first 111 # Note: avoiding yield from because we want Python2 compatibility 112 for item in wrapped_fragments(rest, width, indent): 113 yield item 114 115 116def wrap_text_with_indentation(text, width=80): 117 """ 118 Word-wraps the given text, respecting indentation, to the given 119 width. If there is a line that's more-indented than the given width, 120 wrapping will happen as aggressively as possible after that. Wraps 121 only at spaces. Replicates tab/space mix of indentation in the 122 output. 123 124 This function does NOT optimize for line lengths, and instead just 125 breaks each line greedily as close to the given width as possible. 126 """ 127 lines = text.splitlines() 128 wrapped = [] 129 for line in lines: 130 if len(line) <= width: 131 wrapped.append(line) 132 else: 133 indentation = '' 134 for c in line: 135 if c in ' \t': 136 indentation += c 137 else: 138 break 139 140 wrapped.extend( 141 wrapped_fragments( 142 line[len(indentation):], 143 width, 144 indentation 145 ) 146 ) 147 148 return '\n'.join(wrapped) 149 150 151def indent(string, indent): 152 """ 153 Indents a string using spaces. Newlines will be '\\n' afterwards. 154 """ 155 lines = string.splitlines() 156 return '\n'.join(' ' * indent + line for line in lines) 157 158 159def truncate(text, limit=50000, tag='\n...truncated...'): 160 """ 161 Truncates the given text so that it does not exceed the given limit 162 (in terms of characters; default 50K) 163 164 If the text actually is truncated, the string '\\n...truncated...' 165 will be added to the end of the result to indicate this, but an 166 alternate tag may be specified; the tag characters are not counted 167 against the limit, so it's possible for this function to actually 168 make a string longer if it starts out longer than the limit by less 169 than the length of the tag. 170 """ 171 if len(text) > limit: 172 return text[:limit] + tag 173 else: 174 return text 175 176 177#------------------# 178# Common HTML bits # 179#------------------# 180 181def create_help_button(help_content): 182 """ 183 Returns an HTML string for a button that can be clicked to display 184 help. The button will have a question mark when collapsed and a dash 185 when expanded, and the help will be displayed in a box on top of 186 other content to the right of the help button. 187 """ 188 return ( 189 '<details class="help_button">\n' 190 + '<summary aria-label="More details"></summary>\n' 191 + '<div class="help">{}</div>\n' 192 + '</details>\n' 193 ).format(help_content) 194 195 196def build_list(items, ordered=False): 197 """ 198 Builds an HTML ul tag, or an ol tag if `ordered` is set to True. 199 Items must be a list of (possibly HTML) strings. 200 """ 201 tag = "ol" if ordered else "ul" 202 return ( 203 "<{tag}>\n{items}\n</{tag}>" 204 ).format( 205 tag=tag, 206 items="\n".join("<li>{}</li>".format(item) for item in items) 207 ) 208 209 210def build_html_details(title, content, classes=None): 211 """ 212 Builds an HTML details element with the given title as its summary 213 and content inside. The classes are attached to the details element 214 if provided, and should be a string. 215 """ 216 return ( 217 '<details{}><summary>{}</summary>{}</details>' 218 ).format( 219 ' class="{}"'.format(classes) if classes is not None else "", 220 title, 221 content 222 ) 223 224 225def build_html_tabs(tabs): 226 """ 227 Builds an HTML structure which uses several tab elements and a 228 scrollable region to implement a multiple-tabs structure. The given 229 tabs list should contain title, content pairs, where both titles and 230 contents are strings that may contain HTML code. 231 232 `resources/potluck.js` defines the Javascript code necessary for the 233 tabs to work correctly. 234 """ 235 tab_pieces = [ 236 ( 237 """ 238<li 239 class='tab{selected}' 240 tabindex="0" 241 aria-label="Activate to display {title} after this list." 242> 243{title} 244</li> 245""" .format( 246 selected=" selected" if i == 0 else "", 247 title=title 248 ), 249 """ 250<section 251 class='tab-content{selected}' 252 tabindex="0" 253> 254{content} 255</section> 256""" .format( 257 selected=" selected" if i == 0 else "", 258 content=content 259 ) 260 ) 261 for i, (title, content) in enumerate(tabs) 262 ] 263 tabs = '\n\n'.join([piece[0] for piece in tab_pieces]) 264 contents = '\n\n'.join([piece[1] for piece in tab_pieces]) 265 return """ 266<div class="tabs" role="presentation"> 267 <ul class="tabs-top"> 268{tabs} 269 </ul> 270 <div class="tabs-bot" aria-live="polite"> 271{contents} 272 </div> 273</div> 274""" .format( 275 tabs=tabs, 276 contents=contents 277 ) 278 279 280def fileslug(filename): 281 """ 282 Returns a safe-for-HTML-ID version of the given filename. 283 """ 284 return filename\ 285 .replace(os.path.sep, '-')\ 286 .replace('.py', '')\ 287 .replace('.', '-') 288 289 290def line_id(taskid, filename, lineno): 291 """ 292 Generates an HTML ID for a code line, given the task ID, file name, 293 and line number. 294 """ 295 return "{taskid}_{slug}_codeline_{lineno}".format( 296 taskid=taskid, 297 slug=fileslug(filename), 298 lineno=lineno 299 ) 300 301 302def block_id(taskid, filename): 303 """ 304 Generates an HTML ID for a code block, given the task ID and file 305 name. 306 """ 307 return "{taskid}_code_{slug}".format( 308 taskid=taskid, 309 slug=fileslug(filename) 310 ) 311 312 313def html_link_to_line(taskid, filename, lineno): 314 """ 315 Returns an HTML anchor tag (as a string) that links to the specified 316 line of code in the specified file of the specified task. 317 318 `resources/potluck.js` includes code that will add click handlers to 319 these links so that the line being jumped to gets highlighted. 320 """ 321 lineid = line_id(taskid, filename, lineno) 322 return ( 323 '<a class="lineref" data-lineid="{lineid}"' 324 ' href="#{lineid}">{lineno}</a>' 325 ).format( 326 lineid=lineid, 327 lineno=lineno 328 ) 329 330 331def html_output_details(raw_output, title="Output"): 332 """ 333 Takes raw program output and turns it into an expandable details tag 334 with pre formatting. 335 """ 336 return ( 337 '<details>\n' 338 + '<summary>{}</summary>\n' 339 + '<pre class="printed_output">{}</pre>\n' 340 '</details>' 341 ).format( 342 title, 343 raw_output 344 ) 345 346 347def html_diff_table( 348 output, 349 reference, 350 out_title='Actual output', 351 ref_title='Expected output', 352 joint_title=None, 353 line_limit=300, 354 trim_lines=1024 355): 356 """ 357 Uses difflib to create and return an HTML string that encodes a table 358 comparing two (potentially multi-line) outputs. If joint_title is 359 given, the result is wrapped into a details tag with that title and 360 class diff_table. 361 362 If line_limit is not None (the default is 300) then only that many 363 lines of each output will be included, and a message about the 364 limitation will be added. 365 366 If the trim_lines value is None (the default is 1024) then the full 367 value of every line will be included no matter how long it is, 368 otherwise lines beyond that length will be trimmed to that length 369 (with three periods added to indicate this). 370 """ 371 if isinstance(output, str): 372 output = output.split('\n') 373 if isinstance(reference, str): 374 reference = reference.split('\n') 375 376 if line_limit and len(output) > line_limit or len(reference) > line_limit: 377 result = "(comparing the first {})<br>\n".format( 378 phrasing.obj_num(line_limit, "line") 379 ) 380 output = output[:line_limit] 381 reference = reference[:line_limit] 382 else: 383 result = "" 384 385 if ( 386 trim_lines 387 and ( 388 any(len(line) > trim_lines for line in output) 389 or any(len(line) > trim_lines for line in reference) 390 ) 391 ): 392 result += ( 393 "(comparing the first {} on each line)<br>\n" 394 ).format(phrasing.obj_num(trim_lines, "character")) 395 output = [ 396 line[:trim_lines] + ('...' if len(line) > trim_lines else '') 397 for line in output 398 ] 399 reference = [ 400 line[:trim_lines] + ('...' if len(line) > trim_lines else '') 401 for line in reference 402 ] 403 404 result += difflib.HtmlDiff().make_table( 405 output, 406 reference, 407 fromdesc=out_title, 408 todesc=ref_title, 409 ) 410 if joint_title is None: 411 return result 412 else: 413 return build_html_details(joint_title, result, classes="diff_table") 414 415 416#---------------------# 417# Multimedia elements # 418#---------------------# 419 420def html_image(image, alt_text, classes=None): 421 """ 422 Given a PIL image and associated alt text, builds an HTML image tag 423 that uses a data URL to display the provided image. A list of CSS 424 class strings may be provided to include in the tag. 425 """ 426 src_bytes = io.BytesIO() 427 image.save(src_bytes, format="PNG") 428 data = base64.standard_b64encode(src_bytes.getvalue()).decode("utf-8") 429 if classes is not None: 430 class_attr = 'class="' + ' '.join(classes) + '" ' 431 else: 432 class_attr = '' 433 return ( 434 '<img {}src="data:image/png;base64,{}" alt="{}">' 435 ).format(class_attr, data, alt_text) 436 437 438def html_animation(frames, alt_text, classes=None, delays=10, loop=True): 439 """ 440 Given a list of PIL images and associated alt text, builds an HTML 441 image tag that uses a data URL to display the provided images as 442 frames of an animation. A list of CSS class strings may be provided 443 to include in the tag. 444 445 If delays is provided, it specifies the delay in milliseconds between 446 each frame of the animation. A list of numbers with length equal to 447 the number of frames may be provided to set separate delays for each 448 frame, or a single number will set the same delay for each frame. 449 450 If loop is set to false, the animation will play only once (not 451 recommended). 452 """ 453 src_bytes = io.BytesIO() 454 frames[0].save( 455 src_bytes, 456 format="GIF", 457 append_images=frames[1:], 458 save_all=True, 459 duration=delays, 460 loop=0 if loop else None # TODO: Does this work? 461 ) 462 data = base64.standard_b64encode(src_bytes.getvalue()).decode("utf-8") 463 if classes is not None: 464 class_attr = 'class="' + ' '.join(classes) + '" ' 465 else: 466 class_attr = '' 467 return ( 468 '<img {}src="data:image/gif;base64,{}" alt="{}">' 469 ).format(class_attr, data, alt_text) 470 471 472def html_audio(raw_data, mimetype, label=None): 473 """ 474 Returns an HTML audio tag that will play the provided audio data, 475 using a data URL. For reasons, the data URL is included twice, so the 476 data's size will be more than doubled in the HTML output (TODO: Don't 477 do that?!). If a string is specified as the label, an aria-label 478 value will be attached to the audio element, although this will 479 usually only be accessible to those using screen readers. 480 """ 481 # Construct label attribute 482 label_attr = '' 483 if label is not None: 484 label_attr = ' aria-label="{}"'.format(label) 485 486 # Encode data and construct data URL 487 data = base64.standard_b64encode(raw_data).decode("utf-8") 488 data_url = 'data:{};base64,{}'.format(mimetype, data) 489 490 # Construct and return tag 491 return '''\ 492<audio controls{label}><source type="{mime}" src="{data_url}"> 493(Your browser does not support the audio element. Use this link to download the file and play it using a media player: <a href="{data_url}">download audio</a>) 494</audio>'''.format( # noqa E501 495 label=label_attr, 496 mime=mimetype, 497 data_url=data_url 498 ) 499 500 501#-------------------# 502# Status indicators # 503#-------------------# 504 505def status_css_class(status): 506 """ 507 Returns the CSS class used to indicate the given status. 508 """ 509 return "status_" + status.replace(' ', '_') 510 511 512def build_status_indicator(status): 513 """ 514 Builds a single div that indicates the given goal accomplishment 515 status. 516 """ 517 status_class = status_css_class(status) 518 status_symbol = STATUS_SYMBOLS.get(status, "!") 519 return '<div class="goal_status {stc}">{sy}</div>'.format( 520 stc=status_class, 521 sy=status_symbol 522 ) 523 524 525#------------------------------# 526# General Object Reprs in HTML # 527#------------------------------# 528 529 530def big_repr(thing): 531 """ 532 A function that works like repr, but with some extra formatting for 533 special known cases when the results would be too large. 534 """ 535 base = repr(thing) 536 if len(base) > SHORT_REPR_LIMIT: 537 if type(thing) in (tuple, list, set): # NOT isinstance 538 # left and right delimeters 539 ld, rd = repr(type(thing)()) 540 stuff = ',\n'.join(big_repr(elem) for elem in thing) 541 return ld + '\n' + indent(stuff, 2) + '\n' + rd 542 elif type(thing) is dict: # NOT isinstance 543 stuff = ',\n'.join( 544 big_repr(key) + ": " + big_repr(value) 545 for key, value in thing.items() 546 ) 547 return '{\n' + indent(stuff, 2) + '\n}' 548 elif type(thing) is str and '\n' in thing: 549 return "'''\\\n{thing}'''".format(thing=thing) 550 # else we fall out and return base 551 552 return base 553 554 555def build_display_box(text): 556 """ 557 Creates a disabled textarea element holding the given text. This 558 allows for a finite-size element to contain (potentially a lot) of 559 text that the user can scroll through or even search through. 560 """ 561 # TODO: A disabled textarea is such a hack! Instead, use an inner pre 562 # inside an outer div with CSS to make it scrollable. 563 return '<textarea class="display_box" disabled>{text}</textarea>'.format( 564 text=text 565 ) 566 567 568def dynamic_html_repr(thing, reasonable=200, limit=10000): 569 """ 570 A dynamic representation of an object which is simply a pre-formatted 571 string for objects whose representations are reasonably small, and 572 which turns into a display box for representations which are larger, 573 with text being truncated after a (very large) limit. 574 575 The threshold for reasonably small representations as well as the 576 hard limit may be customized; use None for the hard limit to disable 577 truncation entirely (but don't complain about file sizes if you do). 578 """ 579 rep = big_repr(thing) 580 if len(rep) <= reasonable: 581 return '<pre class="short_repr">{rep}</pre>'.format( 582 rep=escape(rep) 583 ) 584 else: 585 return build_display_box(escape(truncate(rep, limit))) 586 587 588#-------------------------------# 589# Formatting tracebacks as HTML # 590#-------------------------------# 591 592def html_traceback(exc=None, title=None, linkable=None): 593 """ 594 In an exception handler, returns an HTML string that includes the 595 exception type, message, and traceback. Must be called from an except 596 clause, unless an exception object with a __traceback__ value is 597 provided. 598 599 If title is given and not None, a details tag will be returned 600 using that title, which can be expanded to show the traceback, 601 otherwise just a pre tag is returned containing the traceback. 602 603 If linkable is given, it must be a dictionary mapping filenames to 604 task IDs, and line numbers of those files which appear in the 605 traceback will be turned into links to those lines. 606 """ 607 result = escape(string_traceback(exc)) 608 if linkable: 609 for fn in linkable: 610 taskid = linkable[fn] 611 612 def replacer(match): 613 link = html_link_to_line(taskid, fn, int(match.group(1))) 614 return '{}", line {},'.format(fn, link) 615 616 pat = r'{}", line ([0-9]+),'.format(fn) 617 result = re.sub(pat, replacer, result) 618 619 pre = '<pre class="traceback">\n{}\n</pre>'.format(result) 620 if title is not None: 621 return build_html_details(title, pre, "error") 622 else: 623 return pre 624 625 626REWRITES = { 627 file_utils.potluck_src_dir(): "<potluck>" 628} 629""" 630The mapping from filenames to replacements to be used for rewriting 631filenames in tracebacks. 632""" 633 634 635def set_tb_rewrite(filename, rewrite_as): 636 """ 637 Sets up a rewriting rule that will be applied to string and HTML 638 tracebacks. You must provide the filename that should be rewritten, 639 and the string to replace it with. Set rewrite_as to None to remove a 640 previously-established rewrite rule. 641 """ 642 global REWRITES 643 REWRITES[filename] = rewrite_as 644 645 646def string_traceback(exc=None): 647 """ 648 When called in an exception handler, returns a multi-line string 649 including what Python would normally print: the exception type, 650 message, and a traceback. You can also call it anywhere if you can 651 provide an exception object which has a __traceback__ value. 652 653 The traceback gets obfuscated by replacing full file paths that start 654 with the potluck directory with just the package directory and 655 filename, and by replacing full file paths that start with the spec 656 directory with just the task ID and then the file path from the spec 657 directory (usually starter/, soln/, or the submitted file itself). 658 """ 659 sfd = os.path.split(file_utils.get_spec_file_name())[0] 660 rewrites = {} 661 rewrites.update(REWRITES) 662 if sfd: 663 rewrites[sfd] = "<task>" 664 665 if exc is None: 666 raw = traceback.format_exc() 667 else: 668 raw = ''.join(traceback.format_tb(exc.__traceback__) + [ str(exc) ]) 669 670 return rewrite_traceback_filenames(raw, rewrites) 671 672 673def rewrite_traceback_filenames(raw_traceback, prefix_map=None): 674 """ 675 Accepts a traceback as a string, and returns a modified string where 676 filenames have been altered by replacing the keys of the given 677 prefix_map with their values. In addition, filenames which include a 678 directory that ends in "__tmp" will have all directory entries up to 679 and including that one stripped from their path. 680 681 If no prefix map is given, the value of REWRITES will be used. 682 """ 683 prefix_map = prefix_map or REWRITES 684 result = raw_traceback 685 for prefix in prefix_map: 686 replace = prefix_map[prefix] 687 result = result.replace( 688 'File "{prefix}'.format(prefix=prefix), 689 'File "{replace}'.format(replace=replace) 690 ) 691 692 result = re.sub( 693 'File ".*__tmp' + os.path.sep, 694 'File "<submission>/', 695 result 696 ) 697 698 return result 699 700 701#-----------------------------# 702# Function def/call templates # 703#-----------------------------# 704 705def function_def_code_tags(fn_name, params_pattern, announce=None): 706 """ 707 Returns a tuple containing two strings of HTML code used to represent 708 the given function definition in both short and long formats. The 709 short format just lists the first acceptable definition, while the 710 long format lists all of them. Note that both fn_name and 711 params_pattern may be lists of strings instead of strings; see 712 function_def_patterns. 713 """ 714 if isinstance(fn_name, str): 715 names = [fn_name] 716 else: 717 names = list(fn_name) 718 719 # If there are specific parameters we can give users more info about 720 # what they need to do. 721 if isinstance(params_pattern, str): 722 specific_names = [ 723 "{}({})".format(name, params_pattern) for name in names 724 ] 725 else: 726 specific_names = names 727 728 # Figure out what we're announcing as: 729 if announce is None: 730 announce = specific_names[0] 731 732 # Make code tag and detailed code tag: 733 code_tag = "<code>{}</code>".format(announce) 734 details_code = phrasing.comma_list( 735 ["<code>{}</code>".format(n) for n in specific_names], 736 junction="or" 737 ) 738 739 # Add a comment about the number of parameters required 740 if isinstance(params_pattern, int): 741 with_n = " with {} {}".format( 742 params_pattern, 743 phrasing.plural(params_pattern, "parameter") 744 ) 745 code_tag += with_n 746 details_code += with_n 747 748 return code_tag, details_code 749 750 751def function_call_code_tags(fn_name, args_pattern, is_method=False): 752 """ 753 Works like `potluck.patterns.function_call_patterns`, but generates a 754 pair of HTML strings with summary and detailed descriptions of the 755 function call. In that sense it's also similar to 756 `function_def_code_tags`, except that it works for a function call 757 instead of a function definition. 758 759 If the args_pattern is "-any arguments-", the parentheses for the 760 function call will be omitted entirely. 761 """ 762 if isinstance(fn_name, str): 763 names = [fn_name] 764 else: 765 names = list(fn_name) 766 767 # If there are specific args we can give users more info about what 768 # they need to do. 769 if args_pattern == "-any arguments-": 770 specific_names = names 771 elif isinstance(args_pattern, str): 772 if is_method: 773 specific_names = [ 774 ".{}({})".format(name, args_pattern) 775 for name in names 776 ] 777 else: 778 specific_names = [ 779 "{}({})".format(name, args_pattern) 780 for name in names 781 ] 782 else: 783 specific_names = names 784 785 # Make code tag and detailed code tag: 786 code_tag = "<code>{}</code>".format( 787 escape(specific_names[0]) 788 ) 789 790 details_code = phrasing.comma_list( 791 [ 792 "<code>{}</code>".format(escape(name)) 793 for name in specific_names 794 ], 795 junction="or" 796 ) 797 798 return code_tag, details_code 799 800 801def args_repr_list(args, kwargs): 802 """ 803 Creates an HTML string representation of the given positional and 804 keyword arguments, as a bulleted list. 805 """ 806 arg_items = [] 807 for arg in args: 808 arg_items.append(dynamic_html_repr(arg)) 809 810 for kw in kwargs: 811 key_repr = dynamic_html_repr(kw) 812 val_repr = dynamic_html_repr(kwargs[kw]) 813 arg_items.append(key_repr + "=" + val_repr) 814 815 return build_list(arg_items)
The symbols used as shorthand icons for each goal accomplishment status.
Limit in terms of characters before we try advanced formatting.
57def len_as_text(html_string): 58 """ 59 Returns an approximate length of the given string in characters 60 assuming HTML tags would not be visible and HTML entities would be 61 single characters. 62 """ 63 without_tags = re.sub("<[^>]*>", "", html_string) 64 without_entities = re.sub("&[#0-9A-Za-z]+;", ".", without_tags) 65 return len(without_entities)
Returns an approximate length of the given string in characters assuming HTML tags would not be visible and HTML entities would be single characters.
73def wrapped_fragments(line, width=80, indent=''): 74 """ 75 Wraps a single line of text into one or more lines, yielding a 76 sequence of strings. Breaks on spaces/tabs only, and does not 77 attempt any kind of optimization but simply breaks greedily as close 78 to the given width as possible. The given indentation will be 79 prepended to each line, including the first. 80 """ 81 # Apply indentation 82 line = indent + line 83 84 if len(line) <= width: 85 yield line 86 return 87 88 # Look backwards from width for a place we can break at 89 breakpoint = None 90 for i in range(width, len(indent), -1): 91 # (note: breaking on first char doesn't make sense) 92 c = line[i] 93 if c in ' \t': 94 breakpoint = i 95 break 96 97 # If we didn't find a breakpoint that would respect the 98 # target width, find the first one we can 99 if breakpoint is None: 100 for i in range(width, len(line)): 101 c = line[i] 102 if c in ' \t': 103 breakpoint = i 104 105 if breakpoint is None: 106 # Can't find any breakpoint :'( 107 yield line 108 else: 109 first = line[:breakpoint] 110 rest = line[breakpoint + 1:] 111 yield first 112 # Note: avoiding yield from because we want Python2 compatibility 113 for item in wrapped_fragments(rest, width, indent): 114 yield item
Wraps a single line of text into one or more lines, yielding a sequence of strings. Breaks on spaces/tabs only, and does not attempt any kind of optimization but simply breaks greedily as close to the given width as possible. The given indentation will be prepended to each line, including the first.
117def wrap_text_with_indentation(text, width=80): 118 """ 119 Word-wraps the given text, respecting indentation, to the given 120 width. If there is a line that's more-indented than the given width, 121 wrapping will happen as aggressively as possible after that. Wraps 122 only at spaces. Replicates tab/space mix of indentation in the 123 output. 124 125 This function does NOT optimize for line lengths, and instead just 126 breaks each line greedily as close to the given width as possible. 127 """ 128 lines = text.splitlines() 129 wrapped = [] 130 for line in lines: 131 if len(line) <= width: 132 wrapped.append(line) 133 else: 134 indentation = '' 135 for c in line: 136 if c in ' \t': 137 indentation += c 138 else: 139 break 140 141 wrapped.extend( 142 wrapped_fragments( 143 line[len(indentation):], 144 width, 145 indentation 146 ) 147 ) 148 149 return '\n'.join(wrapped)
Word-wraps the given text, respecting indentation, to the given width. If there is a line that's more-indented than the given width, wrapping will happen as aggressively as possible after that. Wraps only at spaces. Replicates tab/space mix of indentation in the output.
This function does NOT optimize for line lengths, and instead just breaks each line greedily as close to the given width as possible.
152def indent(string, indent): 153 """ 154 Indents a string using spaces. Newlines will be '\\n' afterwards. 155 """ 156 lines = string.splitlines() 157 return '\n'.join(' ' * indent + line for line in lines)
Indents a string using spaces. Newlines will be '\n' afterwards.
160def truncate(text, limit=50000, tag='\n...truncated...'): 161 """ 162 Truncates the given text so that it does not exceed the given limit 163 (in terms of characters; default 50K) 164 165 If the text actually is truncated, the string '\\n...truncated...' 166 will be added to the end of the result to indicate this, but an 167 alternate tag may be specified; the tag characters are not counted 168 against the limit, so it's possible for this function to actually 169 make a string longer if it starts out longer than the limit by less 170 than the length of the tag. 171 """ 172 if len(text) > limit: 173 return text[:limit] + tag 174 else: 175 return text
Truncates the given text so that it does not exceed the given limit (in terms of characters; default 50K)
If the text actually is truncated, the string '\n...truncated...' will be added to the end of the result to indicate this, but an alternate tag may be specified; the tag characters are not counted against the limit, so it's possible for this function to actually make a string longer if it starts out longer than the limit by less than the length of the tag.
197def build_list(items, ordered=False): 198 """ 199 Builds an HTML ul tag, or an ol tag if `ordered` is set to True. 200 Items must be a list of (possibly HTML) strings. 201 """ 202 tag = "ol" if ordered else "ul" 203 return ( 204 "<{tag}>\n{items}\n</{tag}>" 205 ).format( 206 tag=tag, 207 items="\n".join("<li>{}</li>".format(item) for item in items) 208 )
Builds an HTML ul tag, or an ol tag if ordered
is set to True.
Items must be a list of (possibly HTML) strings.
211def build_html_details(title, content, classes=None): 212 """ 213 Builds an HTML details element with the given title as its summary 214 and content inside. The classes are attached to the details element 215 if provided, and should be a string. 216 """ 217 return ( 218 '<details{}><summary>{}</summary>{}</details>' 219 ).format( 220 ' class="{}"'.format(classes) if classes is not None else "", 221 title, 222 content 223 )
Builds an HTML details element with the given title as its summary and content inside. The classes are attached to the details element if provided, and should be a string.
226def build_html_tabs(tabs): 227 """ 228 Builds an HTML structure which uses several tab elements and a 229 scrollable region to implement a multiple-tabs structure. The given 230 tabs list should contain title, content pairs, where both titles and 231 contents are strings that may contain HTML code. 232 233 `resources/potluck.js` defines the Javascript code necessary for the 234 tabs to work correctly. 235 """ 236 tab_pieces = [ 237 ( 238 """ 239<li 240 class='tab{selected}' 241 tabindex="0" 242 aria-label="Activate to display {title} after this list." 243> 244{title} 245</li> 246""" .format( 247 selected=" selected" if i == 0 else "", 248 title=title 249 ), 250 """ 251<section 252 class='tab-content{selected}' 253 tabindex="0" 254> 255{content} 256</section> 257""" .format( 258 selected=" selected" if i == 0 else "", 259 content=content 260 ) 261 ) 262 for i, (title, content) in enumerate(tabs) 263 ] 264 tabs = '\n\n'.join([piece[0] for piece in tab_pieces]) 265 contents = '\n\n'.join([piece[1] for piece in tab_pieces]) 266 return """ 267<div class="tabs" role="presentation"> 268 <ul class="tabs-top"> 269{tabs} 270 </ul> 271 <div class="tabs-bot" aria-live="polite"> 272{contents} 273 </div> 274</div> 275""" .format( 276 tabs=tabs, 277 contents=contents 278 )
Builds an HTML structure which uses several tab elements and a scrollable region to implement a multiple-tabs structure. The given tabs list should contain title, content pairs, where both titles and contents are strings that may contain HTML code.
resources/potluck.js
defines the Javascript code necessary for the
tabs to work correctly.
281def fileslug(filename): 282 """ 283 Returns a safe-for-HTML-ID version of the given filename. 284 """ 285 return filename\ 286 .replace(os.path.sep, '-')\ 287 .replace('.py', '')\ 288 .replace('.', '-')
Returns a safe-for-HTML-ID version of the given filename.
291def line_id(taskid, filename, lineno): 292 """ 293 Generates an HTML ID for a code line, given the task ID, file name, 294 and line number. 295 """ 296 return "{taskid}_{slug}_codeline_{lineno}".format( 297 taskid=taskid, 298 slug=fileslug(filename), 299 lineno=lineno 300 )
Generates an HTML ID for a code line, given the task ID, file name, and line number.
303def block_id(taskid, filename): 304 """ 305 Generates an HTML ID for a code block, given the task ID and file 306 name. 307 """ 308 return "{taskid}_code_{slug}".format( 309 taskid=taskid, 310 slug=fileslug(filename) 311 )
Generates an HTML ID for a code block, given the task ID and file name.
314def html_link_to_line(taskid, filename, lineno): 315 """ 316 Returns an HTML anchor tag (as a string) that links to the specified 317 line of code in the specified file of the specified task. 318 319 `resources/potluck.js` includes code that will add click handlers to 320 these links so that the line being jumped to gets highlighted. 321 """ 322 lineid = line_id(taskid, filename, lineno) 323 return ( 324 '<a class="lineref" data-lineid="{lineid}"' 325 ' href="#{lineid}">{lineno}</a>' 326 ).format( 327 lineid=lineid, 328 lineno=lineno 329 )
Returns an HTML anchor tag (as a string) that links to the specified line of code in the specified file of the specified task.
resources/potluck.js
includes code that will add click handlers to
these links so that the line being jumped to gets highlighted.
332def html_output_details(raw_output, title="Output"): 333 """ 334 Takes raw program output and turns it into an expandable details tag 335 with pre formatting. 336 """ 337 return ( 338 '<details>\n' 339 + '<summary>{}</summary>\n' 340 + '<pre class="printed_output">{}</pre>\n' 341 '</details>' 342 ).format( 343 title, 344 raw_output 345 )
Takes raw program output and turns it into an expandable details tag with pre formatting.
348def html_diff_table( 349 output, 350 reference, 351 out_title='Actual output', 352 ref_title='Expected output', 353 joint_title=None, 354 line_limit=300, 355 trim_lines=1024 356): 357 """ 358 Uses difflib to create and return an HTML string that encodes a table 359 comparing two (potentially multi-line) outputs. If joint_title is 360 given, the result is wrapped into a details tag with that title and 361 class diff_table. 362 363 If line_limit is not None (the default is 300) then only that many 364 lines of each output will be included, and a message about the 365 limitation will be added. 366 367 If the trim_lines value is None (the default is 1024) then the full 368 value of every line will be included no matter how long it is, 369 otherwise lines beyond that length will be trimmed to that length 370 (with three periods added to indicate this). 371 """ 372 if isinstance(output, str): 373 output = output.split('\n') 374 if isinstance(reference, str): 375 reference = reference.split('\n') 376 377 if line_limit and len(output) > line_limit or len(reference) > line_limit: 378 result = "(comparing the first {})<br>\n".format( 379 phrasing.obj_num(line_limit, "line") 380 ) 381 output = output[:line_limit] 382 reference = reference[:line_limit] 383 else: 384 result = "" 385 386 if ( 387 trim_lines 388 and ( 389 any(len(line) > trim_lines for line in output) 390 or any(len(line) > trim_lines for line in reference) 391 ) 392 ): 393 result += ( 394 "(comparing the first {} on each line)<br>\n" 395 ).format(phrasing.obj_num(trim_lines, "character")) 396 output = [ 397 line[:trim_lines] + ('...' if len(line) > trim_lines else '') 398 for line in output 399 ] 400 reference = [ 401 line[:trim_lines] + ('...' if len(line) > trim_lines else '') 402 for line in reference 403 ] 404 405 result += difflib.HtmlDiff().make_table( 406 output, 407 reference, 408 fromdesc=out_title, 409 todesc=ref_title, 410 ) 411 if joint_title is None: 412 return result 413 else: 414 return build_html_details(joint_title, result, classes="diff_table")
Uses difflib to create and return an HTML string that encodes a table comparing two (potentially multi-line) outputs. If joint_title is given, the result is wrapped into a details tag with that title and class diff_table.
If line_limit is not None (the default is 300) then only that many lines of each output will be included, and a message about the limitation will be added.
If the trim_lines value is None (the default is 1024) then the full value of every line will be included no matter how long it is, otherwise lines beyond that length will be trimmed to that length (with three periods added to indicate this).
421def html_image(image, alt_text, classes=None): 422 """ 423 Given a PIL image and associated alt text, builds an HTML image tag 424 that uses a data URL to display the provided image. A list of CSS 425 class strings may be provided to include in the tag. 426 """ 427 src_bytes = io.BytesIO() 428 image.save(src_bytes, format="PNG") 429 data = base64.standard_b64encode(src_bytes.getvalue()).decode("utf-8") 430 if classes is not None: 431 class_attr = 'class="' + ' '.join(classes) + '" ' 432 else: 433 class_attr = '' 434 return ( 435 '<img {}src="data:image/png;base64,{}" alt="{}">' 436 ).format(class_attr, data, alt_text)
Given a PIL image and associated alt text, builds an HTML image tag that uses a data URL to display the provided image. A list of CSS class strings may be provided to include in the tag.
439def html_animation(frames, alt_text, classes=None, delays=10, loop=True): 440 """ 441 Given a list of PIL images and associated alt text, builds an HTML 442 image tag that uses a data URL to display the provided images as 443 frames of an animation. A list of CSS class strings may be provided 444 to include in the tag. 445 446 If delays is provided, it specifies the delay in milliseconds between 447 each frame of the animation. A list of numbers with length equal to 448 the number of frames may be provided to set separate delays for each 449 frame, or a single number will set the same delay for each frame. 450 451 If loop is set to false, the animation will play only once (not 452 recommended). 453 """ 454 src_bytes = io.BytesIO() 455 frames[0].save( 456 src_bytes, 457 format="GIF", 458 append_images=frames[1:], 459 save_all=True, 460 duration=delays, 461 loop=0 if loop else None # TODO: Does this work? 462 ) 463 data = base64.standard_b64encode(src_bytes.getvalue()).decode("utf-8") 464 if classes is not None: 465 class_attr = 'class="' + ' '.join(classes) + '" ' 466 else: 467 class_attr = '' 468 return ( 469 '<img {}src="data:image/gif;base64,{}" alt="{}">' 470 ).format(class_attr, data, alt_text)
Given a list of PIL images and associated alt text, builds an HTML image tag that uses a data URL to display the provided images as frames of an animation. A list of CSS class strings may be provided to include in the tag.
If delays is provided, it specifies the delay in milliseconds between each frame of the animation. A list of numbers with length equal to the number of frames may be provided to set separate delays for each frame, or a single number will set the same delay for each frame.
If loop is set to false, the animation will play only once (not recommended).
473def html_audio(raw_data, mimetype, label=None): 474 """ 475 Returns an HTML audio tag that will play the provided audio data, 476 using a data URL. For reasons, the data URL is included twice, so the 477 data's size will be more than doubled in the HTML output (TODO: Don't 478 do that?!). If a string is specified as the label, an aria-label 479 value will be attached to the audio element, although this will 480 usually only be accessible to those using screen readers. 481 """ 482 # Construct label attribute 483 label_attr = '' 484 if label is not None: 485 label_attr = ' aria-label="{}"'.format(label) 486 487 # Encode data and construct data URL 488 data = base64.standard_b64encode(raw_data).decode("utf-8") 489 data_url = 'data:{};base64,{}'.format(mimetype, data) 490 491 # Construct and return tag 492 return '''\ 493<audio controls{label}><source type="{mime}" src="{data_url}"> 494(Your browser does not support the audio element. Use this link to download the file and play it using a media player: <a href="{data_url}">download audio</a>) 495</audio>'''.format( # noqa E501 496 label=label_attr, 497 mime=mimetype, 498 data_url=data_url 499 )
Returns an HTML audio tag that will play the provided audio data, using a data URL. For reasons, the data URL is included twice, so the data's size will be more than doubled in the HTML output (TODO: Don't do that?!). If a string is specified as the label, an aria-label value will be attached to the audio element, although this will usually only be accessible to those using screen readers.
506def status_css_class(status): 507 """ 508 Returns the CSS class used to indicate the given status. 509 """ 510 return "status_" + status.replace(' ', '_')
Returns the CSS class used to indicate the given status.
513def build_status_indicator(status): 514 """ 515 Builds a single div that indicates the given goal accomplishment 516 status. 517 """ 518 status_class = status_css_class(status) 519 status_symbol = STATUS_SYMBOLS.get(status, "!") 520 return '<div class="goal_status {stc}">{sy}</div>'.format( 521 stc=status_class, 522 sy=status_symbol 523 )
Builds a single div that indicates the given goal accomplishment status.
531def big_repr(thing): 532 """ 533 A function that works like repr, but with some extra formatting for 534 special known cases when the results would be too large. 535 """ 536 base = repr(thing) 537 if len(base) > SHORT_REPR_LIMIT: 538 if type(thing) in (tuple, list, set): # NOT isinstance 539 # left and right delimeters 540 ld, rd = repr(type(thing)()) 541 stuff = ',\n'.join(big_repr(elem) for elem in thing) 542 return ld + '\n' + indent(stuff, 2) + '\n' + rd 543 elif type(thing) is dict: # NOT isinstance 544 stuff = ',\n'.join( 545 big_repr(key) + ": " + big_repr(value) 546 for key, value in thing.items() 547 ) 548 return '{\n' + indent(stuff, 2) + '\n}' 549 elif type(thing) is str and '\n' in thing: 550 return "'''\\\n{thing}'''".format(thing=thing) 551 # else we fall out and return base 552 553 return base
A function that works like repr, but with some extra formatting for special known cases when the results would be too large.
556def build_display_box(text): 557 """ 558 Creates a disabled textarea element holding the given text. This 559 allows for a finite-size element to contain (potentially a lot) of 560 text that the user can scroll through or even search through. 561 """ 562 # TODO: A disabled textarea is such a hack! Instead, use an inner pre 563 # inside an outer div with CSS to make it scrollable. 564 return '<textarea class="display_box" disabled>{text}</textarea>'.format( 565 text=text 566 )
Creates a disabled textarea element holding the given text. This allows for a finite-size element to contain (potentially a lot) of text that the user can scroll through or even search through.
569def dynamic_html_repr(thing, reasonable=200, limit=10000): 570 """ 571 A dynamic representation of an object which is simply a pre-formatted 572 string for objects whose representations are reasonably small, and 573 which turns into a display box for representations which are larger, 574 with text being truncated after a (very large) limit. 575 576 The threshold for reasonably small representations as well as the 577 hard limit may be customized; use None for the hard limit to disable 578 truncation entirely (but don't complain about file sizes if you do). 579 """ 580 rep = big_repr(thing) 581 if len(rep) <= reasonable: 582 return '<pre class="short_repr">{rep}</pre>'.format( 583 rep=escape(rep) 584 ) 585 else: 586 return build_display_box(escape(truncate(rep, limit)))
A dynamic representation of an object which is simply a pre-formatted string for objects whose representations are reasonably small, and which turns into a display box for representations which are larger, with text being truncated after a (very large) limit.
The threshold for reasonably small representations as well as the hard limit may be customized; use None for the hard limit to disable truncation entirely (but don't complain about file sizes if you do).
593def html_traceback(exc=None, title=None, linkable=None): 594 """ 595 In an exception handler, returns an HTML string that includes the 596 exception type, message, and traceback. Must be called from an except 597 clause, unless an exception object with a __traceback__ value is 598 provided. 599 600 If title is given and not None, a details tag will be returned 601 using that title, which can be expanded to show the traceback, 602 otherwise just a pre tag is returned containing the traceback. 603 604 If linkable is given, it must be a dictionary mapping filenames to 605 task IDs, and line numbers of those files which appear in the 606 traceback will be turned into links to those lines. 607 """ 608 result = escape(string_traceback(exc)) 609 if linkable: 610 for fn in linkable: 611 taskid = linkable[fn] 612 613 def replacer(match): 614 link = html_link_to_line(taskid, fn, int(match.group(1))) 615 return '{}", line {},'.format(fn, link) 616 617 pat = r'{}", line ([0-9]+),'.format(fn) 618 result = re.sub(pat, replacer, result) 619 620 pre = '<pre class="traceback">\n{}\n</pre>'.format(result) 621 if title is not None: 622 return build_html_details(title, pre, "error") 623 else: 624 return pre
In an exception handler, returns an HTML string that includes the exception type, message, and traceback. Must be called from an except clause, unless an exception object with a __traceback__ value is provided.
If title is given and not None, a details tag will be returned using that title, which can be expanded to show the traceback, otherwise just a pre tag is returned containing the traceback.
If linkable is given, it must be a dictionary mapping filenames to task IDs, and line numbers of those files which appear in the traceback will be turned into links to those lines.
The mapping from filenames to replacements to be used for rewriting filenames in tracebacks.
636def set_tb_rewrite(filename, rewrite_as): 637 """ 638 Sets up a rewriting rule that will be applied to string and HTML 639 tracebacks. You must provide the filename that should be rewritten, 640 and the string to replace it with. Set rewrite_as to None to remove a 641 previously-established rewrite rule. 642 """ 643 global REWRITES 644 REWRITES[filename] = rewrite_as
Sets up a rewriting rule that will be applied to string and HTML tracebacks. You must provide the filename that should be rewritten, and the string to replace it with. Set rewrite_as to None to remove a previously-established rewrite rule.
647def string_traceback(exc=None): 648 """ 649 When called in an exception handler, returns a multi-line string 650 including what Python would normally print: the exception type, 651 message, and a traceback. You can also call it anywhere if you can 652 provide an exception object which has a __traceback__ value. 653 654 The traceback gets obfuscated by replacing full file paths that start 655 with the potluck directory with just the package directory and 656 filename, and by replacing full file paths that start with the spec 657 directory with just the task ID and then the file path from the spec 658 directory (usually starter/, soln/, or the submitted file itself). 659 """ 660 sfd = os.path.split(file_utils.get_spec_file_name())[0] 661 rewrites = {} 662 rewrites.update(REWRITES) 663 if sfd: 664 rewrites[sfd] = "<task>" 665 666 if exc is None: 667 raw = traceback.format_exc() 668 else: 669 raw = ''.join(traceback.format_tb(exc.__traceback__) + [ str(exc) ]) 670 671 return rewrite_traceback_filenames(raw, rewrites)
When called in an exception handler, returns a multi-line string including what Python would normally print: the exception type, message, and a traceback. You can also call it anywhere if you can provide an exception object which has a __traceback__ value.
The traceback gets obfuscated by replacing full file paths that start with the potluck directory with just the package directory and filename, and by replacing full file paths that start with the spec directory with just the task ID and then the file path from the spec directory (usually starter/, soln/, or the submitted file itself).
674def rewrite_traceback_filenames(raw_traceback, prefix_map=None): 675 """ 676 Accepts a traceback as a string, and returns a modified string where 677 filenames have been altered by replacing the keys of the given 678 prefix_map with their values. In addition, filenames which include a 679 directory that ends in "__tmp" will have all directory entries up to 680 and including that one stripped from their path. 681 682 If no prefix map is given, the value of REWRITES will be used. 683 """ 684 prefix_map = prefix_map or REWRITES 685 result = raw_traceback 686 for prefix in prefix_map: 687 replace = prefix_map[prefix] 688 result = result.replace( 689 'File "{prefix}'.format(prefix=prefix), 690 'File "{replace}'.format(replace=replace) 691 ) 692 693 result = re.sub( 694 'File ".*__tmp' + os.path.sep, 695 'File "<submission>/', 696 result 697 ) 698 699 return result
Accepts a traceback as a string, and returns a modified string where filenames have been altered by replacing the keys of the given prefix_map with their values. In addition, filenames which include a directory that ends in "__tmp" will have all directory entries up to and including that one stripped from their path.
If no prefix map is given, the value of REWRITES will be used.
802def args_repr_list(args, kwargs): 803 """ 804 Creates an HTML string representation of the given positional and 805 keyword arguments, as a bulleted list. 806 """ 807 arg_items = [] 808 for arg in args: 809 arg_items.append(dynamic_html_repr(arg)) 810 811 for kw in kwargs: 812 key_repr = dynamic_html_repr(kw) 813 val_repr = dynamic_html_repr(kwargs[kw]) 814 arg_items.append(key_repr + "=" + val_repr) 815 816 return build_list(arg_items)
Creates an HTML string representation of the given positional and keyword arguments, as a bulleted list.