1# 2# Copyright (C) 2016-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> 19 20import os 21import sys 22import resource 23import traceback 24import datetime 25from textwrap import TextWrapper 26from contextlib import contextmanager 27 28import click 29from click import UsageError 30 31# Import buildstream public symbols 32from .. import Scope 33 34# Import various buildstream internals 35from .._context import Context 36from .._platform import Platform 37from .._project import Project 38from .._exceptions import BstError, StreamError, LoadError, LoadErrorReason, AppError 39from .._message import Message, MessageType, unconditional_messages 40from .._stream import Stream 41from .._versions import BST_FORMAT_VERSION 42from .. import _yaml 43from .._scheduler import ElementJob, JobStatus 44 45# Import frontend assets 46from . import Profile, LogLine, Status 47 48# Intendation for all logging 49INDENT = 4 50 51 52# App() 53# 54# Main Application State 55# 56# Args: 57# main_options (dict): The main CLI options of the `bst` 58# command, before any subcommand 59# 60class App(): 61 62 def __init__(self, main_options): 63 64 # 65 # Public members 66 # 67 self.context = None # The Context object 68 self.stream = None # The Stream object 69 self.project = None # The toplevel Project object 70 self.logger = None # The LogLine object 71 self.interactive = None # Whether we are running in interactive mode 72 self.colors = None # Whether to use colors in logging 73 74 # 75 # Private members 76 # 77 self._session_start = datetime.datetime.now() 78 self._session_name = None 79 self._main_options = main_options # Main CLI options, before any command 80 self._status = None # The Status object 81 self._fail_messages = {} # Failure messages by unique plugin id 82 self._interactive_failures = None # Whether to handle failures interactively 83 self._started = False # Whether a session has started 84 85 # UI Colors Profiles 86 self._content_profile = Profile(fg='yellow') 87 self._format_profile = Profile(fg='cyan', dim=True) 88 self._success_profile = Profile(fg='green') 89 self._error_profile = Profile(fg='red', dim=True) 90 self._detail_profile = Profile(dim=True) 91 92 # 93 # Earily initialization 94 # 95 is_a_tty = sys.stdout.isatty() and sys.stderr.isatty() 96 97 # Enable interactive mode if we're attached to a tty 98 if main_options['no_interactive']: 99 self.interactive = False 100 else: 101 self.interactive = is_a_tty 102 103 # Handle errors interactively if we're in interactive mode 104 # and --on-error was not specified on the command line 105 if main_options.get('on_error') is not None: 106 self._interactive_failures = False 107 else: 108 self._interactive_failures = self.interactive 109 110 # Use color output if we're attached to a tty, unless 111 # otherwise specified on the comand line 112 if main_options['colors'] is None: 113 self.colors = is_a_tty 114 elif main_options['colors']: 115 self.colors = True 116 else: 117 self.colors = False 118 119 # Increase the soft limit for open file descriptors to the maximum. 120 # SafeHardlinks FUSE needs to hold file descriptors for all processes in the sandbox. 121 # Avoid hitting the limit too quickly. 122 limits = resource.getrlimit(resource.RLIMIT_NOFILE) 123 if limits[0] != limits[1]: 124 # Set soft limit to hard limit 125 resource.setrlimit(resource.RLIMIT_NOFILE, (limits[1], limits[1])) 126 127 # create() 128 # 129 # Should be used instead of the regular constructor. 130 # 131 # This will select a platform specific App implementation 132 # 133 # Args: 134 # The same args as the App() constructor 135 # 136 @classmethod 137 def create(cls, *args, **kwargs): 138 if sys.platform.startswith('linux'): 139 # Use an App with linux specific features 140 from .linuxapp import LinuxApp 141 return LinuxApp(*args, **kwargs) 142 else: 143 # The base App() class is default 144 return App(*args, **kwargs) 145 146 # initialized() 147 # 148 # Context manager to initialize the application and optionally run a session 149 # within the context manager. 150 # 151 # This context manager will take care of catching errors from within the 152 # context and report them consistently, so the CLI need not take care of 153 # reporting the errors and exiting with a consistent error status. 154 # 155 # Args: 156 # session_name (str): The name of the session, or None for no session 157 # 158 # Note that the except_ argument may have a subtly different meaning depending 159 # on the activity performed on the Pipeline. In normal circumstances the except_ 160 # argument excludes elements from the `elements` list. In a build session, the 161 # except_ elements are excluded from the tracking plan. 162 # 163 # If a session_name is provided, we treat the block as a session, and print 164 # the session header and summary, and time the main session from startup time. 165 # 166 @contextmanager 167 def initialized(self, *, session_name=None): 168 directory = self._main_options['directory'] 169 config = self._main_options['config'] 170 171 self._session_name = session_name 172 173 # 174 # Load the Context 175 # 176 try: 177 self.context = Context() 178 self.context.load(config) 179 except BstError as e: 180 self._error_exit(e, "Error loading user configuration") 181 182 # Override things in the context from our command line options, 183 # the command line when used, trumps the config files. 184 # 185 override_map = { 186 'strict': '_strict_build_plan', 187 'debug': 'log_debug', 188 'verbose': 'log_verbose', 189 'error_lines': 'log_error_lines', 190 'message_lines': 'log_message_lines', 191 'on_error': 'sched_error_action', 192 'fetchers': 'sched_fetchers', 193 'builders': 'sched_builders', 194 'pushers': 'sched_pushers', 195 'network_retries': 'sched_network_retries' 196 } 197 for cli_option, context_attr in override_map.items(): 198 option_value = self._main_options.get(cli_option) 199 if option_value is not None: 200 setattr(self.context, context_attr, option_value) 201 try: 202 Platform.get_platform() 203 except BstError as e: 204 self._error_exit(e, "Error instantiating platform") 205 206 # Create the logger right before setting the message handler 207 self.logger = LogLine(self.context, 208 self._content_profile, 209 self._format_profile, 210 self._success_profile, 211 self._error_profile, 212 self._detail_profile, 213 indent=INDENT) 214 215 # Propagate pipeline feedback to the user 216 self.context.set_message_handler(self._message_handler) 217 218 # Preflight the artifact cache after initializing logging, 219 # this can cause messages to be emitted. 220 try: 221 self.context.artifactcache.preflight() 222 except BstError as e: 223 self._error_exit(e, "Error instantiating artifact cache") 224 225 # 226 # Load the Project 227 # 228 try: 229 self.project = Project(directory, self.context, cli_options=self._main_options['option'], 230 default_mirror=self._main_options.get('default_mirror')) 231 except LoadError as e: 232 233 # Let's automatically start a `bst init` session in this case 234 if e.reason == LoadErrorReason.MISSING_PROJECT_CONF and self.interactive: 235 click.echo("A project was not detected in the directory: {}".format(directory), err=True) 236 click.echo("", err=True) 237 if click.confirm("Would you like to create a new project here ?"): 238 self.init_project(None) 239 240 self._error_exit(e, "Error loading project") 241 242 except BstError as e: 243 self._error_exit(e, "Error loading project") 244 245 # Now that we have a logger and message handler, 246 # we can override the global exception hook. 247 sys.excepthook = self._global_exception_handler 248 249 # Create the stream right away, we'll need to pass it around 250 self.stream = Stream(self.context, self.project, self._session_start, 251 session_start_callback=self.session_start_cb, 252 interrupt_callback=self._interrupt_handler, 253 ticker_callback=self._tick, 254 job_start_callback=self._job_started, 255 job_complete_callback=self._job_completed) 256 257 # Create our status printer, only available in interactive 258 self._status = Status(self.context, 259 self._content_profile, self._format_profile, 260 self._success_profile, self._error_profile, 261 self.stream, colors=self.colors) 262 263 # Mark the beginning of the session 264 if session_name: 265 self._message(MessageType.START, session_name) 266 267 # Run the body of the session here, once everything is loaded 268 try: 269 yield 270 except BstError as e: 271 272 # Print a nice summary if this is a session 273 if session_name: 274 elapsed = self.stream.elapsed_time 275 276 if isinstance(e, StreamError) and e.terminated: # pylint: disable=no-member 277 self._message(MessageType.WARN, session_name + ' Terminated', elapsed=elapsed) 278 else: 279 self._message(MessageType.FAIL, session_name, elapsed=elapsed) 280 281 # Notify session failure 282 self._notify("{} failed".format(session_name), "{}".format(e)) 283 284 if self._started: 285 self._print_summary() 286 287 # Exit with the error 288 self._error_exit(e) 289 except RecursionError: 290 click.echo("RecursionError: Depency depth is too large. Maximum recursion depth exceeded.", 291 err=True) 292 sys.exit(-1) 293 294 else: 295 # No exceptions occurred, print session time and summary 296 if session_name: 297 self._message(MessageType.SUCCESS, session_name, elapsed=self.stream.elapsed_time) 298 if self._started: 299 self._print_summary() 300 301 # Notify session success 302 self._notify("{} succeeded".format(session_name), "") 303 304 # init_project() 305 # 306 # Initialize a new BuildStream project, either with the explicitly passed options, 307 # or by starting an interactive session if project_name is not specified and the 308 # application is running in interactive mode. 309 # 310 # Args: 311 # project_name (str): The project name, must be a valid symbol name 312 # format_version (int): The project format version, default is the latest version 313 # element_path (str): The subdirectory to store elements in, default is 'elements' 314 # force (bool): Allow overwriting an existing project.conf 315 # 316 def init_project(self, project_name, format_version=BST_FORMAT_VERSION, element_path='elements', force=False): 317 directory = self._main_options['directory'] 318 directory = os.path.abspath(directory) 319 project_path = os.path.join(directory, 'project.conf') 320 elements_path = os.path.join(directory, element_path) 321 322 try: 323 # Abort if the project.conf already exists, unless `--force` was specified in `bst init` 324 if not force and os.path.exists(project_path): 325 raise AppError("A project.conf already exists at: {}".format(project_path), 326 reason='project-exists') 327 328 if project_name: 329 # If project name was specified, user interaction is not desired, just 330 # perform some validation and write the project.conf 331 _yaml.assert_symbol_name(None, project_name, 'project name') 332 self._assert_format_version(format_version) 333 self._assert_element_path(element_path) 334 335 elif not self.interactive: 336 raise AppError("Cannot initialize a new project without specifying the project name", 337 reason='unspecified-project-name') 338 else: 339 # Collect the parameters using an interactive session 340 project_name, format_version, element_path = \ 341 self._init_project_interactive(project_name, format_version, element_path) 342 343 # Create the directory if it doesnt exist 344 try: 345 os.makedirs(directory, exist_ok=True) 346 except IOError as e: 347 raise AppError("Error creating project directory {}: {}".format(directory, e)) from e 348 349 # Create the elements sub-directory if it doesnt exist 350 try: 351 os.makedirs(elements_path, exist_ok=True) 352 except IOError as e: 353 raise AppError("Error creating elements sub-directory {}: {}" 354 .format(elements_path, e)) from e 355 356 # Dont use ruamel.yaml here, because it doesnt let 357 # us programatically insert comments or whitespace at 358 # the toplevel. 359 try: 360 with open(project_path, 'w') as f: 361 f.write("# Unique project name\n" + 362 "name: {}\n\n".format(project_name) + 363 "# Required BuildStream format version\n" + 364 "format-version: {}\n\n".format(format_version) + 365 "# Subdirectory where elements are stored\n" + 366 "element-path: {}\n".format(element_path)) 367 except IOError as e: 368 raise AppError("Error writing {}: {}".format(project_path, e)) from e 369 370 except BstError as e: 371 self._error_exit(e) 372 373 click.echo("", err=True) 374 click.echo("Created project.conf at: {}".format(project_path), err=True) 375 sys.exit(0) 376 377 # shell_prompt(): 378 # 379 # Creates a prompt for a shell environment, using ANSI color codes 380 # if they are available in the execution context. 381 # 382 # Args: 383 # element (Element): The Element object to resolve a prompt for 384 # 385 # Returns: 386 # (str): The formatted prompt to display in the shell 387 # 388 def shell_prompt(self, element): 389 _, key, dim = element._get_display_key() 390 element_name = element._get_full_name() 391 392 if self.colors: 393 prompt = self._format_profile.fmt('[') + \ 394 self._content_profile.fmt(key, dim=dim) + \ 395 self._format_profile.fmt('@') + \ 396 self._content_profile.fmt(element_name) + \ 397 self._format_profile.fmt(':') + \ 398 self._content_profile.fmt('$PWD') + \ 399 self._format_profile.fmt(']$') + ' ' 400 else: 401 prompt = '[{}@{}:${{PWD}}]$ '.format(key, element_name) 402 403 return prompt 404 405 # cleanup() 406 # 407 # Cleans up application state 408 # 409 # This is called by Click at exit time 410 # 411 def cleanup(self): 412 if self.stream: 413 self.stream.cleanup() 414 415 ############################################################ 416 # Abstract Class Methods # 417 ############################################################ 418 419 # notify() 420 # 421 # Notify the user of something which occurred, this 422 # is intended to grab attention from the user. 423 # 424 # This is guaranteed to only be called in interactive mode 425 # 426 # Args: 427 # title (str): The notification title 428 # text (str): The notification text 429 # 430 def notify(self, title, text): 431 pass 432 433 ############################################################ 434 # Local Functions # 435 ############################################################ 436 437 # Local function for calling the notify() virtual method 438 # 439 def _notify(self, title, text): 440 if self.interactive: 441 self.notify(title, text) 442 443 # Local message propagator 444 # 445 def _message(self, message_type, message, **kwargs): 446 args = dict(kwargs) 447 self.context.message( 448 Message(None, message_type, message, **args)) 449 450 # Exception handler 451 # 452 def _global_exception_handler(self, etype, value, tb): 453 454 # Print the regular BUG message 455 formatted = "".join(traceback.format_exception(etype, value, tb)) 456 self._message(MessageType.BUG, str(value), 457 detail=formatted) 458 459 # If the scheduler has started, try to terminate all jobs gracefully, 460 # otherwise exit immediately. 461 if self.stream.running: 462 self.stream.terminate() 463 else: 464 sys.exit(-1) 465 466 # 467 # Render the status area, conditional on some internal state 468 # 469 def _maybe_render_status(self): 470 471 # If we're suspended or terminating, then dont render the status area 472 if self._status and self.stream and \ 473 not (self.stream.suspended or self.stream.terminated): 474 self._status.render() 475 476 # 477 # Handle ^C SIGINT interruptions in the scheduling main loop 478 # 479 def _interrupt_handler(self): 480 481 # Only handle ^C interactively in interactive mode 482 if not self.interactive: 483 self._status.clear() 484 self.stream.terminate() 485 return 486 487 # Here we can give the user some choices, like whether they would 488 # like to continue, abort immediately, or only complete processing of 489 # the currently ongoing tasks. We can also print something more 490 # intelligent, like how many tasks remain to complete overall. 491 with self._interrupted(): 492 click.echo("\nUser interrupted with ^C\n" + 493 "\n" 494 "Choose one of the following options:\n" + 495 " (c)ontinue - Continue queueing jobs as much as possible\n" + 496 " (q)uit - Exit after all ongoing jobs complete\n" + 497 " (t)erminate - Terminate any ongoing jobs and exit\n" + 498 "\n" + 499 "Pressing ^C again will terminate jobs and exit\n", 500 err=True) 501 502 try: 503 choice = click.prompt("Choice:", 504 value_proc=_prefix_choice_value_proc(['continue', 'quit', 'terminate']), 505 default='continue', err=True) 506 except click.Abort: 507 # Ensure a newline after automatically printed '^C' 508 click.echo("", err=True) 509 choice = 'terminate' 510 511 if choice == 'terminate': 512 click.echo("\nTerminating all jobs at user request\n", err=True) 513 self.stream.terminate() 514 else: 515 if choice == 'quit': 516 click.echo("\nCompleting ongoing tasks before quitting\n", err=True) 517 self.stream.quit() 518 elif choice == 'continue': 519 click.echo("\nContinuing\n", err=True) 520 521 def _tick(self, elapsed): 522 self._maybe_render_status() 523 524 def _job_started(self, job): 525 self._status.add_job(job) 526 self._maybe_render_status() 527 528 def _job_completed(self, job, status): 529 self._status.remove_job(job) 530 self._maybe_render_status() 531 532 # Dont attempt to handle a failure if the user has already opted to 533 # terminate 534 if status == JobStatus.FAIL and not self.stream.terminated: 535 536 if isinstance(job, ElementJob): 537 element = job.element 538 queue = job.queue 539 540 # Get the last failure message for additional context 541 failure = self._fail_messages.get(element._unique_id) 542 543 # XXX This is dangerous, sometimes we get the job completed *before* 544 # the failure message reaches us ?? 545 if not failure: 546 self._status.clear() 547 click.echo("\n\n\nBUG: Message handling out of sync, " + 548 "unable to retrieve failure message for element {}\n\n\n\n\n" 549 .format(element), err=True) 550 else: 551 self._handle_failure(element, queue, failure) 552 else: 553 click.echo("\nTerminating all jobs\n", err=True) 554 self.stream.terminate() 555 556 def _handle_failure(self, element, queue, failure): 557 558 # Handle non interactive mode setting of what to do when a job fails. 559 if not self._interactive_failures: 560 561 if self.context.sched_error_action == 'terminate': 562 self.stream.terminate() 563 elif self.context.sched_error_action == 'quit': 564 self.stream.quit() 565 elif self.context.sched_error_action == 'continue': 566 pass 567 return 568 569 # Interactive mode for element failures 570 with self._interrupted(): 571 572 summary = ("\n{} failure on element: {}\n".format(failure.action_name, element.name) + 573 "\n" + 574 "Choose one of the following options:\n" + 575 " (c)ontinue - Continue queueing jobs as much as possible\n" + 576 " (q)uit - Exit after all ongoing jobs complete\n" + 577 " (t)erminate - Terminate any ongoing jobs and exit\n" + 578 " (r)etry - Retry this job\n") 579 if failure.logfile: 580 summary += " (l)og - View the full log file\n" 581 if failure.sandbox: 582 summary += " (s)hell - Drop into a shell in the failed build sandbox\n" 583 summary += "\nPressing ^C will terminate jobs and exit\n" 584 585 choices = ['continue', 'quit', 'terminate', 'retry'] 586 if failure.logfile: 587 choices += ['log'] 588 if failure.sandbox: 589 choices += ['shell'] 590 591 choice = '' 592 while choice not in ['continue', 'quit', 'terminate', 'retry']: 593 click.echo(summary, err=True) 594 595 self._notify("BuildStream failure", "{} on element {}" 596 .format(failure.action_name, element.name)) 597 598 try: 599 choice = click.prompt("Choice:", default='continue', err=True, 600 value_proc=_prefix_choice_value_proc(choices)) 601 except click.Abort: 602 # Ensure a newline after automatically printed '^C' 603 click.echo("", err=True) 604 choice = 'terminate' 605 606 # Handle choices which you can come back from 607 # 608 if choice == 'shell': 609 click.echo("\nDropping into an interactive shell in the failed build sandbox\n", err=True) 610 try: 611 prompt = self.shell_prompt(element) 612 self.stream.shell(element, Scope.BUILD, prompt, directory=failure.sandbox, isolate=True) 613 except BstError as e: 614 click.echo("Error while attempting to create interactive shell: {}".format(e), err=True) 615 elif choice == 'log': 616 with open(failure.logfile, 'r') as logfile: 617 content = logfile.read() 618 click.echo_via_pager(content) 619 620 if choice == 'terminate': 621 click.echo("\nTerminating all jobs\n", err=True) 622 self.stream.terminate() 623 else: 624 if choice == 'quit': 625 click.echo("\nCompleting ongoing tasks before quitting\n", err=True) 626 self.stream.quit() 627 elif choice == 'continue': 628 click.echo("\nContinuing with other non failing elements\n", err=True) 629 elif choice == 'retry': 630 click.echo("\nRetrying failed job\n", err=True) 631 queue.failed_elements.remove(element) 632 queue.enqueue([element]) 633 634 # 635 # Print the session heading if we've loaded a pipeline and there 636 # is going to be a session 637 # 638 def session_start_cb(self): 639 self._started = True 640 if self._session_name: 641 self.logger.print_heading(self.project, 642 self.stream, 643 log_file=self._main_options['log_file'], 644 styling=self.colors) 645 646 # 647 # Print a summary of the queues 648 # 649 def _print_summary(self): 650 click.echo("", err=True) 651 self.logger.print_summary(self.stream, 652 self._main_options['log_file'], 653 styling=self.colors) 654 655 # _error_exit() 656 # 657 # Exit with an error 658 # 659 # This will print the passed error to stderr and exit the program 660 # with -1 status 661 # 662 # Args: 663 # error (BstError): A BstError exception to print 664 # prefix (str): An optional string to prepend to the error message 665 # 666 def _error_exit(self, error, prefix=None): 667 click.echo("", err=True) 668 main_error = "{}".format(error) 669 if prefix is not None: 670 main_error = "{}: {}".format(prefix, main_error) 671 672 click.echo(main_error, err=True) 673 if error.detail: 674 indent = " " * INDENT 675 detail = '\n' + indent + indent.join(error.detail.splitlines(True)) 676 click.echo("{}".format(detail), err=True) 677 678 sys.exit(-1) 679 680 # 681 # Handle messages from the pipeline 682 # 683 def _message_handler(self, message, context): 684 685 # Drop status messages from the UI if not verbose, we'll still see 686 # info messages and status messages will still go to the log files. 687 if not context.log_verbose and message.message_type == MessageType.STATUS: 688 return 689 690 # Hold on to the failure messages 691 if message.message_type in [MessageType.FAIL, MessageType.BUG] and message.unique_id is not None: 692 self._fail_messages[message.unique_id] = message 693 694 # Send to frontend if appropriate 695 if self.context.silent_messages() and (message.message_type not in unconditional_messages): 696 return 697 698 if self._status: 699 self._status.clear() 700 701 text = self.logger.render(message) 702 click.echo(text, color=self.colors, nl=False, err=True) 703 704 # Maybe render the status area 705 self._maybe_render_status() 706 707 # Additionally log to a file 708 if self._main_options['log_file']: 709 click.echo(text, file=self._main_options['log_file'], color=False, nl=False) 710 711 @contextmanager 712 def _interrupted(self): 713 self._status.clear() 714 try: 715 with self.stream.suspend(): 716 yield 717 finally: 718 self._maybe_render_status() 719 720 # Some validation routines for project initialization 721 # 722 def _assert_format_version(self, format_version): 723 message = "The version must be supported by this " + \ 724 "version of buildstream (0 - {})\n".format(BST_FORMAT_VERSION) 725 726 # Validate that it is an integer 727 try: 728 number = int(format_version) 729 except ValueError as e: 730 raise AppError(message, reason='invalid-format-version') from e 731 732 # Validate that the specified version is supported 733 if number < 0 or number > BST_FORMAT_VERSION: 734 raise AppError(message, reason='invalid-format-version') 735 736 def _assert_element_path(self, element_path): 737 message = "The element path cannot be an absolute path or contain any '..' components\n" 738 739 # Validate the path is not absolute 740 if os.path.isabs(element_path): 741 raise AppError(message, reason='invalid-element-path') 742 743 # Validate that the path does not contain any '..' components 744 path = element_path 745 while path: 746 split = os.path.split(path) 747 path = split[0] 748 basename = split[1] 749 if basename == '..': 750 raise AppError(message, reason='invalid-element-path') 751 752 # _init_project_interactive() 753 # 754 # Collect the user input for an interactive session for App.init_project() 755 # 756 # Args: 757 # project_name (str): The project name, must be a valid symbol name 758 # format_version (int): The project format version, default is the latest version 759 # element_path (str): The subdirectory to store elements in, default is 'elements' 760 # 761 # Returns: 762 # project_name (str): The user selected project name 763 # format_version (int): The user selected format version 764 # element_path (str): The user selected element path 765 # 766 def _init_project_interactive(self, project_name, format_version=BST_FORMAT_VERSION, element_path='elements'): 767 768 def project_name_proc(user_input): 769 try: 770 _yaml.assert_symbol_name(None, user_input, 'project name') 771 except LoadError as e: 772 message = "{}\n\n{}\n".format(e, e.detail) 773 raise UsageError(message) from e 774 return user_input 775 776 def format_version_proc(user_input): 777 try: 778 self._assert_format_version(user_input) 779 except AppError as e: 780 raise UsageError(str(e)) from e 781 return user_input 782 783 def element_path_proc(user_input): 784 try: 785 self._assert_element_path(user_input) 786 except AppError as e: 787 raise UsageError(str(e)) from e 788 return user_input 789 790 w = TextWrapper(initial_indent=' ', subsequent_indent=' ', width=79) 791 792 # Collect project name 793 click.echo("", err=True) 794 click.echo(self._content_profile.fmt("Choose a unique name for your project"), err=True) 795 click.echo(self._format_profile.fmt("-------------------------------------"), err=True) 796 click.echo("", err=True) 797 click.echo(self._detail_profile.fmt( 798 w.fill("The project name is a unique symbol for your project and will be used " 799 "to distinguish your project from others in user preferences, namspaceing " 800 "of your project's artifacts in shared artifact caches, and in any case where " 801 "BuildStream needs to distinguish between multiple projects.")), err=True) 802 click.echo("", err=True) 803 click.echo(self._detail_profile.fmt( 804 w.fill("The project name must contain only alphanumeric characters, " 805 "may not start with a digit, and may contain dashes or underscores.")), err=True) 806 click.echo("", err=True) 807 project_name = click.prompt(self._content_profile.fmt("Project name"), 808 value_proc=project_name_proc, err=True) 809 click.echo("", err=True) 810 811 # Collect format version 812 click.echo(self._content_profile.fmt("Select the minimum required format version for your project"), err=True) 813 click.echo(self._format_profile.fmt("-----------------------------------------------------------"), err=True) 814 click.echo("", err=True) 815 click.echo(self._detail_profile.fmt( 816 w.fill("The format version is used to provide users who build your project " 817 "with a helpful error message in the case that they do not have a recent " 818 "enough version of BuildStream supporting all the features which your " 819 "project might use.")), err=True) 820 click.echo("", err=True) 821 click.echo(self._detail_profile.fmt( 822 w.fill("The lowest version allowed is 0, the currently installed version of BuildStream " 823 "supports up to format version {}.".format(BST_FORMAT_VERSION))), err=True) 824 825 click.echo("", err=True) 826 format_version = click.prompt(self._content_profile.fmt("Format version"), 827 value_proc=format_version_proc, 828 default=format_version, err=True) 829 click.echo("", err=True) 830 831 # Collect element path 832 click.echo(self._content_profile.fmt("Select the element path"), err=True) 833 click.echo(self._format_profile.fmt("-----------------------"), err=True) 834 click.echo("", err=True) 835 click.echo(self._detail_profile.fmt( 836 w.fill("The element path is a project subdirectory where element .bst files are stored " 837 "within your project.")), err=True) 838 click.echo("", err=True) 839 click.echo(self._detail_profile.fmt( 840 w.fill("Elements will be displayed in logs as filenames relative to " 841 "the element path, and similarly, dependencies must be expressed as filenames " 842 "relative to the element path.")), err=True) 843 click.echo("", err=True) 844 element_path = click.prompt(self._content_profile.fmt("Element path"), 845 value_proc=element_path_proc, 846 default=element_path, err=True) 847 848 return (project_name, format_version, element_path) 849 850 851# 852# Return a value processor for partial choice matching. 853# The returned values processor will test the passed value with all the item 854# in the 'choices' list. If the value is a prefix of one of the 'choices' 855# element, the element is returned. If no element or several elements match 856# the same input, a 'click.UsageError' exception is raised with a description 857# of the error. 858# 859# Note that Click expect user input errors to be signaled by raising a 860# 'click.UsageError' exception. That way, Click display an error message and 861# ask for a new input. 862# 863def _prefix_choice_value_proc(choices): 864 865 def value_proc(user_input): 866 remaining_candidate = [choice for choice in choices if choice.startswith(user_input)] 867 868 if not remaining_candidate: 869 raise UsageError("Expected one of {}, got {}".format(choices, user_input)) 870 elif len(remaining_candidate) == 1: 871 return remaining_candidate[0] 872 else: 873 raise UsageError("Ambiguous input. '{}' can refer to one of {}".format(user_input, remaining_candidate)) 874 875 return value_proc 876