text: # Copyright 2018 Alexandru Catrina
  #
  # Permission is hereby granted, free of charge, to any person obtaining a copy
  # of this software and associated documentation files (the "Software"), to deal
  # in the Software without restriction, including without limitation the rights
  # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  # copies of the Software, and to permit persons to whom the Software is
  # furnished to do so, subject to the following conditions:
  #
  # The above copyright notice and this permission notice shall be included in
  # all copies or substantial portions of the Software.
  #
  # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  # THE SOFTWARE.
  
  from __future__ import print_function
  
  from timeit import default_timer
  from os import getpid
  
  from buildok.converter import Converter
  from buildok.matcher import Matcher
  from buildok.reader import Reader
  from buildok.report import Report
  
  from buildok.readers.read_me import ReadmeReader
  from buildok.structures.topic import Topic
  from buildok.statements.invoke import InvokeTopic
  from buildok.util.log import Log
  
  
  PID = getpid()
  
  RUN_INSTRUCTIONS = """
      Buildok is ready! Choose a topic to run, either by it's ID or by name (can
      be partial match as well). During the runtime, SIGINT is ignored. Typing
      an ID or a name that does not exist will restart the promter. Leaving the
      field blank will end session.
  """
  
  WARNING_UNSAFE_SHELL = u"""\033[91m
      Detected --unsafe-shell option! Use this option on your own risk only if
      you really know what you're doing! Please check documentation if you're not
      sure what --unsafe-shell is, or better yet, remove it and update your
      guide.\033[0m
  """
  
  
  class Script(object):
      """Automated script wrapper.
  
      Args:
          args         (list): List of shell arguments.
          topic         (str): Topic as a text string.
          steps        (list): List of topic steps.
          last_step     (int): Last step index.
          guide_topics (list): List of guide topics.
          convert      (bool): Convertion flag.
      """
  
      def __init__(self, args):
          self.args = args
          self.topic = None
          self.steps = None
          self.last_step = 0
          self.guide_topics = None
          self.convert = False
          self.fake_run = False
          Log.info("Initializing...")
  
      def setup(self):
          """Setup project path, topic and topic pattern, convertion type.
          """
  
          # Look for a specific topic
          if self.args.topic is not None:
              Topic.set_project_topic(self.args.topic)
              Log.info("Topic changed to: %s" % self.args.topic)
  
          # Set topic pattern
          if self.args.topic_pattern is not None:
              Topic.set_project_topic_pattern(self.args.topic_pattern)
              Log.info("Topic pattern changed to: %s" % self.args.topic_pattern)
  
          # Change project path from current working directory to choosen path
          if self.args.guide is not None:
              Reader.set_project_path(self.args.guide)
              Log.info("Project guide location: %s" % self.args.guide)
  
          # Convert build steps to script before exit
          if self.args.convert is not None:
              Converter.prepare(self.args.convert)
              Log.info("Topic convertion set to: %s" % self.args.convert)
              self.convert = True
  
          # Toggle fake run
          self.fake_run = self.args.fake_run
  
      def run(self, ignore_fails=False):
          """Run all steps for the current selected topic.
          """
  
          if self.fake_run:
              Log.info("Topic '%s' faked run" % self.topic.get_title())
              Report.set_status("FAKED")
          else:
              self.launch_topic(ignore_fails)
  
          if self.convert:
              self.launch_convertion()
  
      def launch_convertion(self):
          """Convert build steps to target convertion.
          """
  
          Log.debug("Converting...")
          Converter.check() and Converter.save(self.steps)
          Log.debug("Conversion done!")
  
      def launch_topic(self, ignore_fails=False, failed=False):
          """Launch topic and run all steps.
          """
  
          total_steps = len(self.topic.get_steps())
          Log.info("Preparing to run %d steps from topic..." % total_steps)
  
          # Loop steps and run each one
          start_time = default_timer()
          Log.debug("Setting start time: %s" % start_time)
  
          success_log = u"\033[92m\u2713 (Success)\033[0m \033[93m%s\033[0m"
          failed_log = u"\033[91m? (Failed)\033[0m \033[95m%s\033[0m"
          error_log = u"\033[91m? (Error) %s\033[0m"
  
          for self.last_step, step in enumerate(self.steps):
              step_count, step_desc = self.last_step + 1, step.get_description()
              Log.debug("Preparing step (%d) %s" % (step_count, step_desc))
              Log.info(u"Running \033[93m%s\033[0m ..." % step.get_step())
              try:
                  success, output = step.run()
                  if success:
                      Log.info(success_log % output)
                  else:
                      Log.info(failed_log % output)
                      if not ignore_fails:
                          failed = True
                          Report.set_error(output)
                          break
                  Report.inc_step(1)
              except KeyboardInterrupt:
                  Log.info("Stopping current step...")
                  Log.warn("Interrupting may lead to unexpected results")
                  Log.warn("Stop master process at your own risk (PID %s)" % PID)
              except Exception as e:
                  failed = True
                  Report.set_error(e)
                  Log.info(error_log % str(e))
                  break
  
          stop_time = default_timer()
          Log.debug("Set stop time: %s" % stop_time)
          Log.debug("Runtime %ss" % (stop_time - start_time))
  
          # Save runtime
          Report.set_runtime(stop_time - start_time)
  
          # Wrap up...
          Log.debug("Done running steps...")
          title = self.topic.get_title()
          if failed:
              Log.info("An error occured while topic '%s' was running" % title)
              Report.set_status("Failed")
          else:
              Log.info("Topic '%s' has ran all steps with no errors" % title)
              Report.set_status("OK")
          Log.debug("Closing...")
  
      def parse(self):
          """Parse guide and extract topics.
  
          User prompter asks to choose a topic from the selection in order to
          know what steps are queued.
          """
  
          rr = ReadmeReader(validate=True)
          rr.read()
          rr.parse()
          Log.info("Parsing guide...")
  
          # Preview scanned guide
          if self.args.preview:
              Log.info("Loading guide preview...")
              rr.preview(150)
  
          # Scan guide for topics
          if self.args.topic is None:
              guide = rr.get_guide()
          else:
              guide = rr.get_guide_by_topic()
          if guide is None:
              Log.fatal("Guide has no such topic")
  
          # Prepare guide topics
          self.guide_topics = guide.get_topics()
  
          # Pair each step with appropriate statement
          if self.args.strict:
              if not all([self.pair_steps(t) for t in self.guide_topics]):
                  Report.set_status("Halted")
                  Report.set_error("Unsupported steps")
                  Log.fatal("Cannot continue because of unsupported steps")
          else:
              for t in self.guide_topics:
                  for step in t.get_steps():
                      pair = Matcher.pair_one(step)
                      if not pair:
                          self.guide_topics.remove(t)
  
          # Scan topics
          topics = [t.get_title() for t in guide.get_topics()]
          topics_len = len(topics)
  
          # Handle no topics
          if topics_len == 0:
              Log.fatal("No topics found")
          else:
              Log.info("Guide has %d topics" % topics_len)
  
          # Handle topics
          Log.info("Found the following topics...")
          print("")
          for pos, name in enumerate(topics):
              print(("%" + str(len(str(topics_len))+5)+"d) %s") % (pos+1, name))
          print(RUN_INSTRUCTIONS)
  
          # Display warnings
          if self.args.unsafe_shell:
              print(WARNING_UNSAFE_SHELL)
  
          # Save topic and topic's steps
          self.topic = self.get_user_input()
          self.steps = self.topic.get_steps()
  
          # Confirm topic
          print("")
          Log.info(u"Matching topic \033[92m%s\033[0m" % self.topic.get_title())
  
          # Patch topic imports
          self.patch_topic_invoke(self.steps)
  
          # Prepare report
          Report.set_total_steps(len(self.steps))
          Report.set_topic(self.topic.get_title())
          return self
  
      def pair_steps(topic):
          """Pair topic steps.
          """
  
          return Matcher.pair_all(topic.get_steps())
  
      def patch_topic_invoke(self, steps):
          """Scan steps for topic invoking.
  
          Replace invoker step with topic's steps.
          """
  
          for i, step in enumerate(steps):
  
              # Skip if step is not an invoker
              if step.action is not InvokeTopic:
                  continue
  
              # Find invoked topic
              topic = step.get_kwarguments().get("topic", "")
              topic_steps = self.get_steps_by_topic(topic)
              self.patch_topic_invoke(topic_steps)
              self.steps[i:i+1] = topic_steps
  
      def get_steps_by_topic(self, topic_name):
          """Retrieve steps by topic name.
  
          Returns an empty list if nothing is found.
          """
  
          for topic in Topic.get_all_topics():
              if topic_name.lower() == topic.get_title().lower():
                  return topic.get_steps()
          return []
  
      def get_user_input(self, attempt=0):
          """Prompt user to choose a topic from a list.
  
          Exists if user leaves blank input or hits C^, otherwise continues
          until it receives a valid topic.
          """
  
          # Prompt for input
          try:
              if attempt > 0:
                  print("")  # spacing only
              user_input = raw_input("    >>> choose topic: ")
              attempt += 1
  
          # Exit on C^
          except KeyboardInterrupt:
              print("")  # append newline after ^C
              Report.set_status("Exit")
              raise Exception
  
          # Exit on blank input
          if len(user_input) == 0:
              Report.set_status("Exit")
              raise Exception
  
          # Validate index or start over
          try:
              pos = int(user_input)
              if pos <= 0 or pos > len(self.guide_topics):
                  raise IndexError
              return self.guide_topics[pos-1]
          except IndexError:
              print("    <<< topic #%d not found" % pos)
              return self.get_user_input(attempt)
          except Exception:
              pass
  
          # Validate full or partial topic name or start over
          try:
              topic = unicode(user_input)
              partial_match = None
              drop_partial = False
              for each in self.guide_topics:
                  if each.get_title().strip() == topic.strip():
                      return each
                  prefixed = each.get_title().startswith(topic)
                  if partial_match is not None and prefixed and not drop_partial:
                      drop_partial = True
                  if partial_match is None and prefixed:
                      partial_match = each
              if not drop_partial and partial_match is not None:
                  return partial_match
              print("    <<< topic \"%s\" not found" % topic)
          except Exception:
              pass
  
          # No valid topic found, start over
          return self.get_user_input(attempt)
  
      def print_report(self):
          """Dumps a report of the script outcome.
          """
  
          Log.info("Preparing to print report...")
          Report.output()
  