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