[Style] Align if modifiers based on surrounding code

require_relative 'version'

class RubyPants < String
  extend RubyPantsVersion

  # Create a new RubyPants instance with the text in +string+.
  #
  # Allowed elements in the options array:
  #
  # 0  :: do nothing
  # 1  :: enable all, using only em-dash shortcuts
  # 2  :: enable all, using old school en- and em-dash shortcuts (*default*)
  # 3  :: enable all, using inverted old school en and em-dash shortcuts
  # -1 :: stupefy (translate HTML entities to their ASCII-counterparts)
  #
  # If you don't like any of these defaults, you can pass symbols to change
  # RubyPants' behavior:
  #
  # <tt>:quotes</tt>         :: quotes
  # <tt>:backticks</tt>      :: backtick quotes (``double'' only)
  # <tt>:allbackticks</tt>   :: backtick quotes (``double'' and `single')
  # <tt>:dashes</tt>         :: dashes
  # <tt>:oldschool</tt>      :: old school dashes
  # <tt>:inverted</tt>       :: inverted old school dashes
  # <tt>:ellipses</tt>       :: ellipses
  # <tt>:prevent_breaks</tt> :: use nbsp and word-joiner to avoid breaking
  #                             before dashes and ellipses
  # <tt>:named_entities</tt> :: used named entities instead of the default
  #                             decimal entities (see below)
  # <tt>:convertquotes</tt>  :: convert <tt>"</tt> entities to
  #                             <tt>"</tt>
  # <tt>:stupefy</tt>        :: translate RubyPants HTML entities
  #                             to their ASCII counterparts.
  #
  # In addition, you can customize the HTML entities that will be injected by
  # passing in a hash for the final argument. The defaults for these entities
  # are as follows:
  #
  # <tt>:single_left_quote</tt>  :: <tt>‘</tt>
  # <tt>:double_left_quote</tt>  :: <tt>“</tt>
  # <tt>:single_right_quote</tt> :: <tt>’</tt>
  # <tt>:double_right_quote</tt> :: <tt>”</tt>
  # <tt>:em_dash</tt>            :: <tt>—</tt>
  # <tt>:en_dash</tt>            :: <tt>–</tt>
  # <tt>:ellipsis</tt>           :: <tt>…</tt>
  # <tt>:non_breaking_space</tt> :: <tt> </tt>
  # <tt>:word_joiner</tt>        :: <tt></tt>
  #
  # If the <tt>:named_entities</tt> option is used, the default entities are
  # as follows:
  #
  # <tt>:single_left_quote</tt>  :: <tt>‘</tt>
  # <tt>:double_left_quote</tt>  :: <tt>“</tt>
  # <tt>:single_right_quote</tt> :: <tt>’</tt>
  # <tt>:double_right_quote</tt> :: <tt>”</tt>
  # <tt>:em_dash</tt>            :: <tt>—</tt>
  # <tt>:en_dash</tt>            :: <tt>–</tt>
  # <tt>:ellipsis</tt>           :: <tt>…</tt>
  # <tt>:non_breaking_space</tt> :: <tt> </tt>
  # <tt>:word_joiner</tt>        :: <tt></tt>
  #
  # If the <tt>:character_entities</tt> option is used, RubyPants will
  # emit Unicode characters directly, rather than HTML entities. By default
  # this excludes the space characters (non-breaking space and
  # word-joiner). To additionally emit Unicode space characters, use the
  # <tt>:character_spaces</tt> option.
  #
  def initialize(string, options=[2], entities = {})
    super string

    @options = [*options]
    @entities = default_entities
    @entities.merge!(named_entities) if @options.include?(:named_entities)
    @entities.merge!(character_entities) if @options.include?(:character_entities)
    @entities.merge!(character_spaces) if @options.include?(:character_spaces)
    @entities.merge!(entities)
  end

  # Apply SmartyPants transformations.
  def to_html
    do_quotes = do_backticks = do_dashes = do_ellipses = do_stupify = nil
    convert_quotes = prevent_breaks = nil

    if @options.include?(0)
      # Do nothing.
      return self
    elsif @options.include?(1)
      # Do everything, turn all options on.
      do_quotes = do_backticks = do_ellipses = true
      do_dashes = :normal
    elsif @options.include?(2)
      # Do everything, turn all options on, use old school dash shorthand.
      do_quotes = do_backticks = do_ellipses = true
      do_dashes = :oldschool
    elsif @options.include?(3)
      # Do everything, turn all options on, use inverted old school
      # dash shorthand.
      do_quotes = do_backticks = do_ellipses = true
      do_dashes = :inverted
    elsif @options.include?(-1)
      do_stupefy = true
    end

    # Explicit flags override numeric flag groups.
    do_quotes      = true if @options.include?(:quotes)
    do_backticks   = true if @options.include?(:backticks)
    do_backticks   = :both if @options.include?(:allbackticks)
    do_dashes      = :normal if @options.include?(:dashes)
    do_dashes      = :oldschool if @options.include?(:oldschool)
    do_dashes      = :inverted if @options.include?(:inverted)
    prevent_breaks = true if @options.include?(:prevent_breaks)
    do_ellipses    = true if @options.include?(:ellipses)
    convert_quotes = true if @options.include?(:convertquotes)
    do_stupefy     = true if @options.include?(:stupefy)

    # Parse the HTML
    tokens = tokenize

    # Keep track of when we're inside
 or  tags.
    in_pre = nil

    # Here is the result stored in.
    result = ""

    # This is a cheat, used to get some context for one-character
    # tokens that consist of just a quote char. What we do is remember
    # the last character of the previous text token, to use as context
    # to curl single- character quote tokens correctly.
    prev_token_last_char = nil

    tokens.each do |token|
      if token.first == :tag
        result << token[1]
        if token[1].end_with? '/>'
          # ignore self-closing tags
        elsif token[1] =~ %r!\A<(/?)(pre|code|kbd|script|style|math)[\s>]!
          if $1 == '' && ! in_pre
            in_pre = $2
          elsif $1 == '/' && $2 == in_pre
            in_pre = nil
          end
        end
      else
        t = token[1]

        # Remember last char of this token before processing.
        last_char = t[-1].chr

        unless in_pre
          t = process_escapes t

          t.gsub!(/"/, '"')  if convert_quotes

          if do_dashes
            t = educate_dashes t, prevent_breaks            if do_dashes == :normal
            t = educate_dashes_oldschool t, prevent_breaks  if do_dashes == :oldschool
            t = educate_dashes_inverted t, prevent_breaks   if do_dashes == :inverted
          end

          t = educate_ellipses t, prevent_breaks  if do_ellipses

          # Note: backticks need to be processed before quotes.
          if do_backticks
            t = educate_backticks t
            t = educate_single_backticks t  if do_backticks == :both
          end

          if do_quotes
            if t == "'"
              # Special case: single-character ' token
              if prev_token_last_char =~ /\S/
                t = entity(:single_right_quote)
              else
                t = entity(:single_left_quote)
              end
            elsif t == '"'
              # Special case: single-character " token
              if prev_token_last_char =~ /\S/
                t = entity(:double_right_quote)
              else
                t = entity(:double_left_quote)
              end
            else
              # Normal case:
              t = educate_quotes t
            end
          end

          t = stupefy_entities t  if do_stupefy
        end

        prev_token_last_char = last_char
        result << t
      end
    end

    # Done
    result
  end

  protected

  # Return the string, with after processing the following backslash
  # escape sequences. This is useful if you want to force a "dumb" quote
  # or other character to appear.
  #
  # Escaped are:
  #      \\    \"    \'    \.    \-    \`
  #
  def process_escapes(str)
    str.
      gsub('\\\\', '\').
      gsub('\"',   '"').
      gsub("\\\'", ''').
      gsub('\.',   '.').
      gsub('\-',   '-').
      gsub('\`',   '`')
  end

  def self.n_of(n, x)
    x = Regexp.escape(x)
    /(?<!#{x})   # not preceded by x
     #{x}{#{n}}  # n of x
     (?!#{x})    # not followed by x
    /x
  end

  DOUBLE_DASH = n_of(2, '-')
  TRIPLE_DASH = n_of(3, '-')

  # Return +str+ replacing all +patt+ with +repl+. If +prevent_breaks+ is true,
  # then replace spaces preceding +patt+ with a non-breaking space, and if there
  # are no spaces, then insert a word-joiner.
  #
  def educate(str, patt, repl, prevent_breaks)
    patt = /(?<spaces>[[:space:]]*)#{patt}/
    str.gsub(patt) do
      spaces = if prevent_breaks && $~['spaces'].length > 0
                 entity(:non_breaking_space) # * $~['spaces'].length
               elsif prevent_breaks
                 entity(:word_joiner)
               else
                 $~['spaces']
               end
      spaces + repl
    end
  end

  # Return the string, with each instance of "<tt>--</tt>" translated to an
  # em-dash HTML entity.
  #
  def educate_dashes(str, prevent_breaks=false)
    educate(str, DOUBLE_DASH, entity(:em_dash), prevent_breaks)
  end

  # Return the string, with each instance of "<tt>--</tt>" translated to an
  # en-dash HTML entity, and each "<tt>---</tt>" translated to an
  # em-dash HTML entity.
  #
  def educate_dashes_oldschool(str, prevent_breaks=false)
    str = educate(str, TRIPLE_DASH, entity(:em_dash), prevent_breaks)
    educate(str, DOUBLE_DASH, entity(:en_dash), prevent_breaks)
  end

  # Return the string, with each instance of "<tt>--</tt>" translated
  # to an em-dash HTML entity, and each "<tt>---</tt>" translated to
  # an en-dash HTML entity. Two reasons why: First, unlike the en- and
  # em-dash syntax supported by +educate_dashes_oldschool+, it's
  # compatible with existing entries written before SmartyPants 1.1,
  # back when "<tt>--</tt>" was only used for em-dashes.  Second,
  # em-dashes are more common than en-dashes, and so it sort of makes
  # sense that the shortcut should be shorter to type. (Thanks to
  # Aaron Swartz for the idea.)
  #
  def educate_dashes_inverted(str, prevent_breaks=false)
    str = educate(str, TRIPLE_DASH, entity(:en_dash), prevent_breaks)
    educate(str, DOUBLE_DASH, entity(:em_dash), prevent_breaks)
  end

  # Return the string, with each instance of "<tt>...</tt>" translated
  # to an ellipsis HTML entity. Also converts the case where there are
  # spaces between the dots.
  #
  def educate_ellipses(str, prevent_breaks=false)
    str = educate(str, RubyPants.n_of(3, '.'), entity(:ellipsis), prevent_breaks)
    educate(str, /(?<!\.|\.[[:space:]])\.[[:space:]]\.[[:space:]]\.(?!\.|[[:space:]]\.)/,
            entity(:ellipsis), prevent_breaks)
  end

  # Return the string, with "<tt>``backticks''</tt>"-style single quotes
  # translated into HTML curly quote entities.
  #
  def educate_backticks(str)
    str.
      gsub("``", entity(:double_left_quote)).
      gsub("''", entity(:double_right_quote))
  end

  # Return the string, with "<tt>`backticks'</tt>"-style single quotes
  # translated into HTML curly quote entities.
  #
  def educate_single_backticks(str)
    str.
      gsub("`", entity(:single_left_quote)).
      gsub("'", entity(:single_right_quote))
  end

  # Return the string, with "educated" curly quote HTML entities.
  #
  def educate_quotes(str)
    punct_class = '[!"#\$\%\'()*+,\-.\/:;<=>?\@\[\\\\\]\^_`{|}~]'

    str = str.dup

    # Special case if the very first character is a quote followed by
    # punctuation at a non-word-break. Close the quotes by brute
    # force:
    str.gsub!(/^'(?=#{punct_class}\B)/,
              entity(:single_right_quote))
    str.gsub!(/^"(?=#{punct_class}\B)/,
              entity(:double_right_quote))

    # Special case for double sets of quotes, e.g.:
    #
He said, "'Quoted' words in a larger quote."

    str.gsub!(/"'(?=\w)/,
              "#{entity(:double_left_quote)}#{entity(:single_left_quote)}")
    str.gsub!(/'"(?=\w)/,
              "#{entity(:single_left_quote)}#{entity(:double_left_quote)}")

    # Special case for decade abbreviations (the '80s):
    str.gsub!(/'(?=\d\ds)/,
              entity(:single_right_quote))

    close_class = %![^\ \t\r\n\\[\{\(\-]!
    dec_dashes = "#{entity(:en_dash)}|#{entity(:em_dash)}"

    # Get most opening single quotes:
    str.gsub!(/([[:space:]]| |--|&[mn]dash;|#{dec_dashes}|&#x201[34];)'(?=\w)/,
             '\1' + entity(:single_left_quote))

    # Single closing quotes:
    str.gsub!(/(#{close_class})'/,
              '\1' + entity(:single_right_quote))
    str.gsub!(/'(\s|s\b|$)/,
              entity(:single_right_quote) + '\1')

    # Any remaining single quotes should be opening ones:
    str.gsub!(/'/,
              entity(:single_left_quote))

    # Get most opening double quotes:
    str.gsub!(/([[:space:]]| |--|&[mn]dash;|#{dec_dashes}|&#x201[34];)"(?=\w)/,
             '\1' + entity(:double_left_quote))

    # Double closing quotes:
    str.gsub!(/(#{close_class})"/,
              '\1' + entity(:double_right_quote))
    str.gsub!(/"(\s|s\b|$)/,
              entity(:double_right_quote) + '\1')

    # Any remaining quotes should be opening ones:
    str.gsub!(/"/,
              entity(:double_left_quote))

    str
  end

  # Return the string, with each RubyPants HTML entity translated to
  # its ASCII counterpart.
  #
  # Note: This is not reversible (but exactly the same as in SmartyPants)
  #
  def stupefy_entities(str)
    new_str = str.dup

    {
      :en_dash            => '-',
      :em_dash            => '--',
      :single_left_quote  => "'",
      :single_right_quote => "'",
      :double_left_quote  => '"',
      :double_right_quote => '"',
      :ellipsis           => '...'
    }.each do |k,v|
      new_str.gsub!(/#{entity(k)}/, v)
    end

    new_str
  end

  # Return an array of the tokens comprising the string. Each token is
  # either a tag (possibly with nested, tags contained therein, such
  # as <tt><a href="<MTFoo>"></tt>, or a run of text between
  # tags. Each element of the array is a two-element array; the first
  # is either :tag or :text; the second is the actual value.
  #
  # Based on the <tt>_tokenize()</tt> subroutine from Brad Choate's
  # MTRegex plugin.  <http://www.bradchoate.com/past/mtregex.php>
  #
  # This is actually the easier variant using tag_soup, as used by
  # Chad Miller in the Python port of SmartyPants.
  #
  def tokenize
    tag_soup = /([^<]*)(|<[^>]*>)/m

    tokens = []

    prev_end = 0

    scan(tag_soup) do
      tokens << [:text, $1]  if $1 != ""
      tokens << [:tag, $2]
      prev_end = $~.end(0)
    end

    if prev_end < size
      tokens << [:text, self[prev_end..-1]]
    end

    tokens
  end

  def default_entities
    {
      :single_left_quote  => "‘",
      :double_left_quote  => "“",
      :single_right_quote => "’",
      :double_right_quote => "”",
      :em_dash            => "—",
      :en_dash            => "–",
      :ellipsis           => "…",
      :non_breaking_space => " ",
      :word_joiner        => "",
    }
  end

  def named_entities
    {
      :single_left_quote  => '‘',
      :double_left_quote  => "“",
      :single_right_quote => "’",
      :double_right_quote => "”",
      :em_dash            => "—",
      :en_dash            => "–",
      :ellipsis           => "…",
      :non_breaking_space => " ",
      # :word_joiner      => N/A,
    }
  end

  def character_entities
    {
      :single_left_quote  => "\u2018",
      :double_left_quote  => "\u201C",
      :single_right_quote => "\u2019",
      :double_right_quote => "\u201D",
      :em_dash            => "\u2014",
      :en_dash            => "\u2013",
      :ellipsis           => "\u2026",
    }
  end

  def character_spaces
    {
      :non_breaking_space => "\u00A0",
      :word_joiner        => "\u2060",
    }
  end

  def entity(key)
    @entities[key]
  end
end


Use GitHub Actions for CI

require 'modelling/version'
require 'ostruct'

module Modelling

  def self.included(receiver)
    receiver.extend ClassMethods
  end

  module ClassMethods
    def inherited(descendant)
      descendant.instance_variable_set(:@members, members.dup)
      super
    end

    def attributes(*args)
      generate_accessors_from_args(args, Proc.new { nil })
    end

    def collections(*args)
      generate_accessors_from_args(args, Proc.new { Array.new })
    end

    def maps(*args)
      generate_accessors_from_args(args, Proc.new { Hash.new })
    end

    def structs(*args)
      generate_accessors_from_args(args, Proc.new { OpenStruct.new })
    end

    def members
      @members ||= {}
    end

    def accessors
      @accessors ||= []
    end

    private

    def generate_accessors_from_args(args, default_initializer)
      names_to_initializer = args.last.is_a?(Hash) ? args.pop : {}
      args.each do |name|
        names_to_initializer[name] = default_initializer
      end
      generate_accessors(names_to_initializer)
    end

    def generate_accessors(names_to_initializer)
      names_to_initializer.each do |name, initializer|
        create_accessor(name)
        if initializer.is_a?(Proc)
          members[name] = initializer
        else
          members[name] = Proc.new { initializer.new }
        end
      end
    end

    def create_accessor(name)
      accessors << name
      instance_eval { attr_accessor name }
    end

  end

  def members
    self.class.members
  end

  def initialize(args = {})
    members.each do |accessor, initializer|
      if initializer.arity > 0
        send "#{accessor}=", initializer.call(self)
      else
        send "#{accessor}=", initializer.call
      end
    end
    args.each { |name, value| send "#{name}=", value }
  end

  def attributes
    hash = {}
    self.class.accessors.each do |method_name|
      hash[method_name] = send(method_name)
    end
    hash
  end

  def inspect
    attributes_as_nice_string = attributes.collect { |name, value|
      "#{name}: #{attribute_for_inspect(value)}"
    }.compact.join(", ")
    "#<#{self.class} #{attributes_as_nice_string}>"
  end

  def attribute_for_inspect(value)
    if value.is_a?(String) && value.length > 50
      "#{value[0..50]}...".inspect
    elsif value.is_a?(Date) || value.is_a?(Time)
      %("#{value.to_s(:db)}")
    elsif value.class.included_modules.include?(Modelling)
      "#<#{value.class.to_s}>"
    else
      value.inspect
    end
  end

end

require 'bundler'
Bundler.require
$: << '.'
require 'modelling'

class User
  include Modelling
  attributes :name, :age
  collections :fav_colours, :biggest_gripes, :test => lambda { 3 }
end

class NonSpecificUser < User
end

class MyArray < Array; end
class MyHash < Hash; end

class Car
  include Modelling
  attributes :name => Proc.new { |car| String.new(car.class.to_s) }
  collections :doors => MyArray
end

class SuperCar < Car
  attributes :bhp => Proc.new { 400 }
end

class Site
  include Modelling
  maps :users => MyHash
end

class Bike
  include Modelling
  attributes :manufacturer
  maps :stickers
  structs :features
end

class LambdaTest
  include Modelling
  attributes :lambda => lambda { "boo" }
end

describe Modelling do

  specify 'user has name' do
    user = User.new
    user.name = 'Ryan'
    user.name.should  eq 'Ryan'
  end

  specify 'user has age' do
    user = User.new
    user.age = 24
    user.age.should  eq 24
  end

  specify 'user has fav colours collection' do
    user = User.new
    user.fav_colours = [:green, :yellow]
    user.fav_colours.should  eq [:green, :yellow]
  end

  specify 'user has biggest gripes collection' do
    user = User.new
    user.biggest_gripes = [:mediocrity, :CUB_beer]
    user.biggest_gripes.should  eq [:mediocrity, :CUB_beer]
  end

  specify 'attributes are initialized as nil' do
    user = User.new
    user.name.should be_nil
    user.age.should be_nil
  end

  specify 'collections are initialized as empty array' do
    user = User.new
    user.fav_colours.should be_empty
    user.biggest_gripes.should be_empty
  end

  it 'can initialize with attributes' do
    user = User.new(:name => 'Ryan')
    user.name.should  eq 'Ryan'
  end

  it 'can initialize with collections' do
    user = User.new(:biggest_gripes => [:mediocrity, :CUB_beer])
    user.biggest_gripes.should  eq [:mediocrity, :CUB_beer]
  end

  specify 'car has doors and all is good' do
    car = Car.new
    car.doors.should be_empty
  end

  specify 'bike does not bunk on having no collection' do
    bike = Bike.new(:manufacturer => 'Kawasaki')
    # if nothing is rased, we fixed the bug
  end

  specify 'bike has map of stickers' do
    Bike.new.stickers.should be_empty
  end

  specify 'cars doors is a my array' do
    Car.new.doors.should be_instance_of MyArray
  end

  specify 'sites users is a my hash' do
    Site.new.users.should be_instance_of MyHash
  end

  it 'can initialize with proc and get reference to new instance' do
    car = Car.new
    car.name.should  eq String.new(car.class.to_s)
  end

  specify 'bike has features' do
    bike = Bike.new
    bike.features.should be_instance_of OpenStruct
  end

  it 'doesnt fail when lambdas with no args are used' do
    LambdaTest.new.lambda.should  eq 'boo'
  end

  specify 'tracks list of accessors' do
    User.accessors.should include :name, :age
  end

  specify 'provides a Hash of attributes and values' do
    User.new.attributes.key?(:name).should be_true
    User.new.attributes.key?(:age).should be_true
    User.new(:name => "Joe").attributes[:name].should eq "Joe"
  end

  specify 'converts the attributes hash to a string for inspect' do
    u = User.new(:name => "Joe")
    u.inspect.should == "#<User name: \"Joe\", age: nil, test: 3, fav_colours: [], biggest_gripes: []>"
  end

  context 'circular references' do

    before do
      class FirstModel
        include Modelling
        attributes :test => lambda { |me| OtherModel.new(me) }
      end

      class OtherModel
        include Modelling
        attributes :owner
        def initialize(owner)
          @owner = owner
        end
      end
    end

    it 'should not raise an error when inspecting' do
      expect { FirstModel.new.inspect }.should_not raise_error(SystemStackError)
    end

    it 'should show the class name instead of inspecting referenced models' do
      FirstModel.new.inspect.should include("#<OtherModel>")
    end

  end

  context 'inheritence' do
    let(:car) { Car.new }
    let(:super_car) { SuperCar.new }

    it 'inherits attributes' do
      super_car.doors.should be_instance_of MyArray
      super_car.name.should  eq "SuperCar"
    end

    it 'allows extra attributes' do
      super_car.bhp.should  eq 400
    end

    it 'doesnt mess up the super classes members' do
      car.should_not respond_to(:bhp)
      car.should respond_to(:doors)
      car.should respond_to(:name)
    end

    specify 'non specific user inherits attributes' do
      user = NonSpecificUser.new(:name => 'Ryan you cocktard')
      user.name.should  eq 'Ryan you cocktard'
    end
  end
end

require 'modelling/version'
require 'ostruct'
require 'time'

module Modelling

  def self.included(receiver)
    receiver.extend ClassMethods
  end

  module ClassMethods
    def inherited(descendant)
      descendant.instance_variable_set(:@members, members.dup)
      super
    end

    def attributes(*args)
      generate_accessors_from_args(args, Proc.new { nil })
    end

    def collections(*args)
      generate_accessors_from_args(args, Proc.new { Array.new })
    end

    def maps(*args)
      generate_accessors_from_args(args, Proc.new { Hash.new })
    end

    def structs(*args)
      generate_accessors_from_args(args, Proc.new { OpenStruct.new })
    end

    def members
      @members ||= {}
    end

    def accessors
      @accessors ||= []
    end

    private

    def generate_accessors_from_args(args, default_initializer)
      names_to_initializer = args.last.is_a?(Hash) ? args.pop : {}
      args.each do |name|
        names_to_initializer[name] = default_initializer
      end
      generate_accessors(names_to_initializer)
    end

    def generate_accessors(names_to_initializer)
      names_to_initializer.each do |name, initializer|
        create_accessor(name)
        if initializer.is_a?(Proc)
          members[name] = initializer
        else
          members[name] = Proc.new { initializer.new }
        end
      end
    end

    def create_accessor(name)
      accessors << name
      instance_eval { attr_accessor name }
    end

  end

  def members
    self.class.members
  end

  def initialize(args = {})
    members.each do |accessor, initializer|
      if initializer.arity > 0
        send "#{accessor}=", initializer.call(self)
      else
        send "#{accessor}=", initializer.call
      end
    end
    args.each { |name, value| send "#{name}=", value }
  end

  def attributes
    hash = {}
    self.class.accessors.each do |method_name|
      hash[method_name] = send(method_name)
    end
    hash
  end

  def inspect
    attributes_as_nice_string = attributes.collect { |name, value|
      "#{name}: #{attribute_for_inspect(value)}"
    }.compact.join(", ")
    "#<#{self.class} #{attributes_as_nice_string}>"
  end

  def attribute_for_inspect(value)
    if value.is_a?(String) && value.length > 50
      "#{value[0..50]}...".inspect
    elsif value.is_a?(Date) || value.is_a?(Time)
      %("#{value.to_s(:db)}")
    elsif value.class.included_modules.include?(Modelling)
      "#<#{value.class.to_s}>"
    else
      value.inspect
    end
  end

end

require 'bundler'
Bundler.require
$: << '.'
require 'modelling'

class User
  include Modelling
  attributes :name, :age
  collections :fav_colours, :biggest_gripes, :test => lambda { 3 }
end

class NonSpecificUser < User
end

class MyArray < Array; end
class MyHash < Hash; end

class Car
  include Modelling
  attributes :name => Proc.new { |car| String.new(car.class.to_s) }
  collections :doors => MyArray
end

class SuperCar < Car
  attributes :bhp => Proc.new { 400 }
end

class Site
  include Modelling
  maps :users => MyHash
end

class Bike
  include Modelling
  attributes :manufacturer
  maps :stickers
  structs :features
end

class LambdaTest
  include Modelling
  attributes :lambda => lambda { "boo" }
end

RSpec.describe Modelling do

  specify 'user has name' do
    user = User.new
    user.name = 'Ryan'
    expect(user.name).to eq 'Ryan'
  end

  specify 'user has age' do
    user = User.new
    user.age = 24
    expect(user.age).to eq 24
  end

  specify 'user has fav colours collection' do
    user = User.new
    user.fav_colours = [:green, :yellow]
    expect(user.fav_colours).to eq [:green, :yellow]
  end

  specify 'user has biggest gripes collection' do
    user = User.new
    user.biggest_gripes = [:mediocrity, :CUB_beer]
    expect(user.biggest_gripes).to eq [:mediocrity, :CUB_beer]
  end

  specify 'attributes are initialized as nil' do
    user = User.new
    expect(user.name).to be_nil
    expect(user.age).to be_nil
  end

  specify 'collections are initialized as empty array' do
    user = User.new
    expect(user.fav_colours).to be_empty
    expect(user.biggest_gripes).to be_empty
  end

  it 'can initialize with attributes' do
    user = User.new(:name => 'Ryan')
    expect(user.name).to eq 'Ryan'
  end

  it 'can initialize with collections' do
    user = User.new(:biggest_gripes => [:mediocrity, :CUB_beer])
    expect(user.biggest_gripes).to eq [:mediocrity, :CUB_beer]
  end

  specify 'car has doors and all is good' do
    car = Car.new
    expect(car.doors).to be_empty
  end

  specify 'bike does not bunk on having no collection' do
    bike = Bike.new(:manufacturer => 'Kawasaki')
    # if nothing is rased, we fixed the bug
  end

  specify 'bike has map of stickers' do
    expect(Bike.new.stickers).to be_empty
  end

  specify 'cars doors is a my array' do
    expect(Car.new.doors).to be_instance_of MyArray
  end

  specify 'sites users is a my hash' do
    expect(Site.new.users).to be_instance_of MyHash
  end

  it 'can initialize with proc and get reference to new instance' do
    car = Car.new
    expect(car.name).to eq String.new(car.class.to_s)
  end

  specify 'bike has features' do
    bike = Bike.new
    expect(bike.features).to be_instance_of OpenStruct
  end

  it 'doesnt fail when lambdas with no args are used' do
    expect(LambdaTest.new.lambda).to eq 'boo'
  end

  specify 'tracks list of accessors' do
    expect(User.accessors).to include :name, :age
  end

  specify 'provides a Hash of attributes and values' do
    expect(User.new.attributes.key?(:name)).to be true
    expect(User.new.attributes.key?(:age)).to be true
    expect(User.new(:name => "Joe").attributes[:name]).to eq "Joe"
  end

  specify 'converts the attributes hash to a string for inspect' do
    u = User.new(:name => "Joe")
    expect(u.inspect).to eq("#<User name: \"Joe\", age: nil, test: 3, fav_colours: [], biggest_gripes: []>")
  end

  context 'circular references' do

    before do
      class FirstModel
        include Modelling
        attributes :test => lambda { |me| OtherModel.new(me) }
      end

      class OtherModel
        include Modelling
        attributes :owner
        def initialize(owner)
          @owner = owner
        end
      end
    end

    it 'should not raise an error when inspecting' do
      expect { FirstModel.new.inspect }.not_to raise_error
    end

    it 'should show the class name instead of inspecting referenced models' do
      expect(FirstModel.new.inspect).to include("#<OtherModel>")
    end

  end

  context 'inheritence' do
    let(:car) { Car.new }
    let(:super_car) { SuperCar.new }

    it 'inherits attributes' do
      expect(super_car.doors).to be_instance_of MyArray
      expect(super_car.name).to eq "SuperCar"
    end

    it 'allows extra attributes' do
      expect(super_car.bhp).to eq 400
    end

    it 'doesnt mess up the super classes members' do
      expect(car).not_to respond_to(:bhp)
      expect(car).to respond_to(:doors)
      expect(car).to respond_to(:name)
    end

    specify 'non specific user inherits attributes' do
      user = NonSpecificUser.new(:name => 'Ryan you cocktard')
      expect(user.name).to eq 'Ryan you cocktard'
    end
  end
end


Bug 1246107 - A spell error of delimeters in mozrunner/__init__.py

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

import os
import sys
import copy
import tempfile
import signal
import commands
import zipfile
import optparse
import killableprocess
import subprocess
import platform
import shutil
from StringIO import StringIO
from xml.dom import minidom

from distutils import dir_util
from time import sleep

# conditional (version-dependent) imports
try:
    import simplejson
except ImportError:
    import json as simplejson

import logging
logger = logging.getLogger(__name__)

# Use dir_util for copy/rm operations because shutil is all kinds of broken
copytree = dir_util.copy_tree
rmtree = dir_util.remove_tree

def findInPath(fileName, path=os.environ['PATH']):
    dirs = path.split(os.pathsep)
    for dir in dirs:
        if os.path.isfile(os.path.join(dir, fileName)):
            return os.path.join(dir, fileName)
        if os.name == 'nt' or sys.platform == 'cygwin':
            if os.path.isfile(os.path.join(dir, fileName + ".exe")):
                return os.path.join(dir, fileName + ".exe")
    return None

stdout = sys.stdout
stderr = sys.stderr
stdin = sys.stdin

def run_command(cmd, env=None, **kwargs):
    """Run the given command in killable process."""
    killable_kwargs = {'stdout':stdout ,'stderr':stderr, 'stdin':stdin}
    killable_kwargs.update(kwargs)

    if sys.platform != "win32":
        return killableprocess.Popen(cmd, preexec_fn=lambda : os.setpgid(0, 0),
                                     env=env, **killable_kwargs)
    else:
        return killableprocess.Popen(cmd, env=env, **killable_kwargs)

def getoutput(l):
    tmp = tempfile.mktemp()
    x = open(tmp, 'w')
    subprocess.call(l, stdout=x, stderr=x)
    x.close(); x = open(tmp, 'r')
    r = x.read() ; x.close()
    os.remove(tmp)
    return r

def get_pids(name, minimun_pid=0):
    """Get all the pids matching name, exclude any pids below minimum_pid."""
    if os.name == 'nt' or sys.platform == 'cygwin':
        import wpk

        pids = wpk.get_pids(name)

    else:
        data = getoutput(['ps', 'ax']).splitlines()
        pids = [int(line.split()[0]) for line in data if line.find(name) is not -1]

    matching_pids = [m for m in pids if m > minimun_pid]
    return matching_pids

def makedirs(name):

    head, tail = os.path.split(name)
    if not tail:
        head, tail = os.path.split(head)
    if head and tail and not os.path.exists(head):
        try:
            makedirs(head)
        except OSError, e:
            pass
        if tail == os.curdir:           # xxx/newdir/. exists if xxx/newdir exists
            return
    try:
        os.mkdir(name)
    except:
        pass

# addon_details() copied from mozprofile
def addon_details(install_rdf_fh):
    """
    returns a dictionary of details about the addon
    - addon_path : path to the addon directory
    Returns:
    {'id':      u'rainbow@colors.org', # id of the addon
     'version': u'1.4',                # version of the addon
     'name':    u'Rainbow',            # name of the addon
     'unpack': # whether to unpack the addon
    """

    details = {
        'id': None,
        'unpack': False,
        'name': None,
        'version': None
    }

    def get_namespace_id(doc, url):
        attributes = doc.documentElement.attributes
        namespace = ""
        for i in range(attributes.length):
            if attributes.item(i).value == url:
                if ":" in attributes.item(i).name:
                    # If the namespace is not the default one remove 'xlmns:'
                    namespace = attributes.item(i).name.split(':')[1] + ":"
                    break
        return namespace

    def get_text(element):
        """Retrieve the text value of a given node"""
        rc = []
        for node in element.childNodes:
            if node.nodeType == node.TEXT_NODE:
                rc.append(node.data)
        return ''.join(rc).strip()

    doc = minidom.parse(install_rdf_fh)

    # Get the namespaces abbreviations
    em = get_namespace_id(doc, "http://www.mozilla.org/2004/em-rdf#")
    rdf = get_namespace_id(doc, "http://www.w3.org/1999/02/22-rdf-syntax-ns#")

    description = doc.getElementsByTagName(rdf + "Description").item(0)
    for node in description.childNodes:
        # Remove the namespace prefix from the tag for comparison
        entry = node.nodeName.replace(em, "")
        if entry in details.keys():
            details.update({ entry: get_text(node) })

    # turn unpack into a true/false value
    if isinstance(details['unpack'], basestring):
        details['unpack'] = details['unpack'].lower() == 'true'

    return details

class Profile(object):
    """Handles all operations regarding profile. Created new profiles, installs extensions,
    sets preferences and handles cleanup."""

    def __init__(self, binary=None, profile=None, addons=None,
                 preferences=None):

        self.binary = binary

        self.create_new = not(bool(profile))
        if profile:
            self.profile = profile
        else:
            self.profile = self.create_new_profile(self.binary)

        self.addons_installed = []
        self.addons = addons or []

        ### set preferences from class preferences
        preferences = preferences or {}
        if hasattr(self.__class__, 'preferences'):
            self.preferences = self.__class__.preferences.copy()
        else:
            self.preferences = {}
        self.preferences.update(preferences)

        for addon in self.addons:
            self.install_addon(addon)

        self.set_preferences(self.preferences)

    def create_new_profile(self, binary):
        """Create a new clean profile in tmp which is a simple empty folder"""
        profile = tempfile.mkdtemp(suffix='.mozrunner')
        return profile

    def unpack_addon(self, xpi_zipfile, addon_path):
        for name in xpi_zipfile.namelist():
            if name.endswith('/'):
                makedirs(os.path.join(addon_path, name))
            else:
                if not os.path.isdir(os.path.dirname(os.path.join(addon_path, name))):
                    makedirs(os.path.dirname(os.path.join(addon_path, name)))
                data = xpi_zipfile.read(name)
                f = open(os.path.join(addon_path, name), 'wb')
                f.write(data) ; f.close()
                zi = xpi_zipfile.getinfo(name)
                os.chmod(os.path.join(addon_path,name), (zi.external_attr>>16))

    def install_addon(self, path):
        """Installs the given addon or directory of addons in the profile."""

        extensions_path = os.path.join(self.profile, 'extensions')
        if not os.path.exists(extensions_path):
            os.makedirs(extensions_path)

        addons = [path]
        if not path.endswith('.xpi') and not os.path.exists(os.path.join(path, 'install.rdf')):
            addons = [os.path.join(path, x) for x in os.listdir(path)]

        for addon in addons:
            if addon.endswith('.xpi'):
                xpi_zipfile = zipfile.ZipFile(addon, "r")
                details = addon_details(StringIO(xpi_zipfile.read('install.rdf')))
                addon_path = os.path.join(extensions_path, details["id"])
                if details.get("unpack", True):
                    self.unpack_addon(xpi_zipfile, addon_path)
                    self.addons_installed.append(addon_path)
                else:
                    shutil.copy(addon, addon_path + '.xpi')
            else:
                # it's already unpacked, but we need to extract the id so we
                # can copy it
                details = addon_details(open(os.path.join(addon, "install.rdf"), "rb"))
                addon_path = os.path.join(extensions_path, details["id"])
                shutil.copytree(addon, addon_path, symlinks=True)

    def set_preferences(self, preferences):
        """Adds preferences dict to profile preferences"""
        prefs_file = os.path.join(self.profile, 'user.js')
        # Ensure that the file exists first otherwise create an empty file
        if os.path.isfile(prefs_file):
            f = open(prefs_file, 'a+')
        else:
            f = open(prefs_file, 'w')

        f.write('\n#MozRunner Prefs Start\n')

        pref_lines = ['user_pref(%s, %s);' %
                      (simplejson.dumps(k), simplejson.dumps(v) ) for k, v in
                       preferences.items()]
        for line in pref_lines:
            f.write(line+'\n')
        f.write('#MozRunner Prefs End\n')
        f.flush() ; f.close()

    def pop_preferences(self):
        """
        pop the last set of preferences added
        returns True if popped
        """

        # our magic markers
        delimeters = ('#MozRunner Prefs Start', '#MozRunner Prefs End')

        lines = file(os.path.join(self.profile, 'user.js')).read().splitlines()
        def last_index(_list, value):
            """
            returns the last index of an item;
            this should actually be part of python code but it isn't
            """
            for index in reversed(range(len(_list))):
                if _list[index] == value:
                    return index
        s = last_index(lines, delimeters[0])
        e = last_index(lines, delimeters[1])

        # ensure both markers are found
        if s is None:
            assert e is None, '%s found without %s' % (delimeters[1], delimeters[0])
            return False # no preferences found
        elif e is None:
            assert e is None, '%s found without %s' % (delimeters[0], delimeters[1])

        # ensure the markers are in the proper order
        assert e > s, '%s found at %s, while %s found at %s' (delimeter[1], e, delimeter[0], s)

        # write the prefs
        cleaned_prefs = '\n'.join(lines[:s] + lines[e+1:])
        f = file(os.path.join(self.profile, 'user.js'), 'w')
        f.write(cleaned_prefs)
        f.close()
        return True

    def clean_preferences(self):
        """Removed preferences added by mozrunner."""
        while True:
            if not self.pop_preferences():
                break

    def clean_addons(self):
        """Cleans up addons in the profile."""
        for addon in self.addons_installed:
            if os.path.isdir(addon):
                rmtree(addon)

    def cleanup(self):
        """Cleanup operations on the profile."""
        def oncleanup_error(function, path, excinfo):
            #TODO: How should we handle this?
            print "Error Cleaning up: " + str(excinfo[1])
        if self.create_new:
            shutil.rmtree(self.profile, False, oncleanup_error)
        else:
            self.clean_preferences()
            self.clean_addons()

class FirefoxProfile(Profile):
    """Specialized Profile subclass for Firefox"""
    preferences = {# Don't automatically update the application
                   'app.update.enabled' : False,
                   # Don't restore the last open set of tabs if the browser has crashed
                   'browser.sessionstore.resume_from_crash': False,
                   # Don't check for the default web browser
                   'browser.shell.checkDefaultBrowser' : False,
                   # Don't warn on exit when multiple tabs are open
                   'browser.tabs.warnOnClose' : False,
                   # Don't warn when exiting the browser
                   'browser.warnOnQuit': False,
                   # Only install add-ons from the profile and the app folder
                   'extensions.enabledScopes' : 5,
                   # Don't automatically update add-ons
                   'extensions.update.enabled'    : False,
                   # Don't open a dialog to show available add-on updates
                   'extensions.update.notifyUser' : False,
                   }

    # The possible names of application bundles on Mac OS X, in order of
    # preference from most to least preferred.
    # Note: Nightly is obsolete, as it has been renamed to FirefoxNightly,
    # but it will still be present if users update an older nightly build
    # via the app update service.
    bundle_names = ['Firefox', 'FirefoxNightly', 'Nightly']

    # The possible names of binaries, in order of preference from most to least
    # preferred.
    @property
    def names(self):
        if sys.platform == 'darwin':
            return ['firefox', 'nightly', 'shiretoko']
        if (sys.platform == 'linux2') or (sys.platform in ('sunos5', 'solaris')):
            return ['firefox', 'mozilla-firefox', 'iceweasel']
        if os.name == 'nt' or sys.platform == 'cygwin':
            return ['firefox']

class ThunderbirdProfile(Profile):
    preferences = {'extensions.update.enabled'    : False,
                   'extensions.update.notifyUser' : False,
                   'browser.shell.checkDefaultBrowser' : False,
                   'browser.tabs.warnOnClose' : False,
                   'browser.warnOnQuit': False,
                   'browser.sessionstore.resume_from_crash': False,
                   }

    # The possible names of application bundles on Mac OS X, in order of
    # preference from most to least preferred.
    bundle_names = ["Thunderbird", "Shredder"]

    # The possible names of binaries, in order of preference from most to least
    # preferred.
    names = ["thunderbird", "shredder"]

class Runner(object):
    """Handles all running operations. Finds bins, runs and kills the process."""

    def __init__(self, binary=None, profile=None, cmdargs=[], env=None,
                 kp_kwargs={}):
        if binary is None:
            self.binary = self.find_binary()
        elif sys.platform == 'darwin' and binary.find('Contents/MacOS/') == -1:
            self.binary = os.path.join(binary, 'Contents/MacOS/%s-bin' % self.names[0])
        else:
            self.binary = binary

        if not os.path.exists(self.binary):
            raise Exception("Binary path does not exist "+self.binary)

        if sys.platform == 'linux2' and self.binary.endswith('-bin'):
            dirname = os.path.dirname(self.binary)
            if os.environ.get('LD_LIBRARY_PATH', None):
                os.environ['LD_LIBRARY_PATH'] = '%s:%s' % (os.environ['LD_LIBRARY_PATH'], dirname)
            else:
                os.environ['LD_LIBRARY_PATH'] = dirname

        # Disable the crash reporter by default
        os.environ['MOZ_CRASHREPORTER_NO_REPORT'] = '1'

        self.profile = profile

        self.cmdargs = cmdargs
        if env is None:
            self.env = copy.copy(os.environ)
            self.env.update({'MOZ_NO_REMOTE':"1",})
        else:
            self.env = env
        self.kp_kwargs = kp_kwargs or {}

    def find_binary(self):
        """Finds the binary for self.names if one was not provided."""
        binary = None
        if sys.platform in ('linux2', 'sunos5', 'solaris') \
                or sys.platform.startswith('freebsd'):
            for name in reversed(self.names):
                binary = findInPath(name)
        elif os.name == 'nt' or sys.platform == 'cygwin':

            # find the default executable from the windows registry
            try:
                import _winreg
            except ImportError:
                pass
            else:
                sam_flags = [0]
                # KEY_WOW64_32KEY etc only appeared in 2.6+, but that's OK as
                # only 2.6+ has functioning 64bit builds.
                if hasattr(_winreg, "KEY_WOW64_32KEY"):
                    if "64 bit" in sys.version:
                        # a 64bit Python should also look in the 32bit registry
                        sam_flags.append(_winreg.KEY_WOW64_32KEY)
                    else:
                        # possibly a 32bit Python on 64bit Windows, so look in
                        # the 64bit registry incase there is a 64bit app.
                        sam_flags.append(_winreg.KEY_WOW64_64KEY)
                for sam_flag in sam_flags:
                    try:
                        # assumes self.app_name is defined, as it should be for
                        # implementors
                        keyname = r"Software\Mozilla\Mozilla %s" % self.app_name
                        sam = _winreg.KEY_READ | sam_flag
                        app_key = _winreg.OpenKey(_winreg.HKEY_LOCAL_MACHINE, keyname, 0, sam)
                        version, _type = _winreg.QueryValueEx(app_key, "CurrentVersion")
                        version_key = _winreg.OpenKey(app_key, version + r"\Main")
                        path, _ = _winreg.QueryValueEx(version_key, "PathToExe")
                        return path
                    except _winreg.error:
                        pass

            # search for the binary in the path
            for name in reversed(self.names):
                binary = findInPath(name)
                if sys.platform == 'cygwin':
                    program_files = os.environ['PROGRAMFILES']
                else:
                    program_files = os.environ['ProgramFiles']

                if binary is None:
                    for bin in [(program_files, 'Mozilla Firefox', 'firefox.exe'),
                                (os.environ.get("ProgramFiles(x86)"),'Mozilla Firefox', 'firefox.exe'),
                                (program_files, 'Nightly', 'firefox.exe'),
                                (os.environ.get("ProgramFiles(x86)"),'Nightly', 'firefox.exe'),
                                (program_files, 'Aurora', 'firefox.exe'),
                                (os.environ.get("ProgramFiles(x86)"),'Aurora', 'firefox.exe')
                                ]:
                        path = os.path.join(*bin)
                        if os.path.isfile(path):
                            binary = path
                            break
        elif sys.platform == 'darwin':
            for bundle_name in self.bundle_names:
                # Look for the application bundle in the user's home directory
                # or the system-wide /Applications directory.  If we don't find
                # it in one of those locations, we move on to the next possible
                # bundle name.
                appdir = os.path.join("~/Applications/%s.app" % bundle_name)
                if not os.path.isdir(appdir):
                    appdir = "/Applications/%s.app" % bundle_name
                if not os.path.isdir(appdir):
                    continue

                # Look for a binary with any of the possible binary names
                # inside the application bundle.
                for binname in self.names:
                    binpath = os.path.join(appdir,
                                           "Contents/MacOS/%s-bin" % binname)
                    if (os.path.isfile(binpath)):
                        binary = binpath
                        break

                if binary:
                    break

        if binary is None:
            raise Exception('Mozrunner could not locate your binary, you will need to set it.')
        return binary

    @property
    def command(self):
        """Returns the command list to run."""
        cmd = [self.binary, '-profile', self.profile.profile]
        # On i386 OS X machines, i386+x86_64 universal binaries need to be told
        # to run as i386 binaries.  If we're not running a i386+x86_64 universal
        # binary, then this command modification is harmless.
        if sys.platform == 'darwin':
            if hasattr(platform, 'architecture') and platform.architecture()[0] == '32bit':
                cmd = ['arch', '-i386'] + cmd
        return cmd

    def get_repositoryInfo(self):
        """Read repository information from application.ini and platform.ini."""
        import ConfigParser

        config = ConfigParser.RawConfigParser()
        dirname = os.path.dirname(self.binary)
        repository = { }

        for entry in [['application', 'App'], ['platform', 'Build']]:
            (file, section) = entry
            config.read(os.path.join(dirname, '%s.ini' % file))

            for entry in [['SourceRepository', 'repository'], ['SourceStamp', 'changeset']]:
                (key, id) = entry

                try:
                    repository['%s_%s' % (file, id)] = config.get(section, key);
                except:
                    repository['%s_%s' % (file, id)] = None

        return repository

    def start(self):
        """Run self.command in the proper environment."""
        if self.profile is None:
            self.profile = self.profile_class()
        self.process_handler = run_command(self.command+self.cmdargs, self.env, **self.kp_kwargs)

    def wait(self, timeout=None):
        """Wait for the browser to exit."""
        self.process_handler.wait(timeout=timeout)

        if sys.platform != 'win32':
            for name in self.names:
                for pid in get_pids(name, self.process_handler.pid):
                    self.process_handler.pid = pid
                    self.process_handler.wait(timeout=timeout)

    def kill(self, kill_signal=signal.SIGTERM):
        """Kill the browser"""
        if sys.platform != 'win32':
            self.process_handler.kill()
            for name in self.names:
                for pid in get_pids(name, self.process_handler.pid):
                    self.process_handler.pid = pid
                    self.process_handler.kill()
        else:
            try:
                self.process_handler.kill(group=True)
                # On windows, it sometimes behooves one to wait for dust to settle
                # after killing processes.  Let's try that.
                # TODO: Bug 640047 is invesitgating the correct way to handle this case
                self.process_handler.wait(timeout=10)
            except Exception, e:
                logger.error('Cannot kill process, '+type(e).__name__+' '+e.message)

    def stop(self):
        self.kill()

class FirefoxRunner(Runner):
    """Specialized Runner subclass for running Firefox."""

    app_name = 'Firefox'
    profile_class = FirefoxProfile

    # The possible names of application bundles on Mac OS X, in order of
    # preference from most to least preferred.
    # Note: Nightly is obsolete, as it has been renamed to FirefoxNightly,
    # but it will still be present if users update an older nightly build
    # only via the app update service.
    bundle_names = ['Firefox', 'FirefoxNightly', 'Nightly']

    @property
    def names(self):
        if sys.platform == 'darwin':
            return ['firefox', 'nightly', 'shiretoko']
        if sys.platform in ('linux2', 'sunos5', 'solaris') \
                or sys.platform.startswith('freebsd'):
            return ['firefox', 'mozilla-firefox', 'iceweasel']
        if os.name == 'nt' or sys.platform == 'cygwin':
            return ['firefox']

class ThunderbirdRunner(Runner):
    """Specialized Runner subclass for running Thunderbird"""

    app_name = 'Thunderbird'
    profile_class = ThunderbirdProfile

    # The possible names of application bundles on Mac OS X, in order of
    # preference from most to least preferred.
    bundle_names = ["Thunderbird", "Shredder"]

    # The possible names of binaries, in order of preference from most to least
    # preferred.
    names = ["thunderbird", "shredder"]

class CLI(object):
    """Command line interface."""

    runner_class = FirefoxRunner
    profile_class = FirefoxProfile
    module = "mozrunner"

    parser_options = {("-b", "--binary",): dict(dest="binary", help="Binary path.",
                                                metavar=None, default=None),
                      ('-p', "--profile",): dict(dest="profile", help="Profile path.",
                                                 metavar=None, default=None),
                      ('-a', "--addons",): dict(dest="addons",
                                                help="Addons paths to install.",
                                                metavar=None, default=None),
                      ("--info",): dict(dest="info", default=False,
                                        action="store_true",
                                        help="Print module information")
                     }

    def __init__(self):
        """ Setup command line parser and parse arguments """
        self.metadata = self.get_metadata_from_egg()
        self.parser = optparse.OptionParser(version="%prog " + self.metadata["Version"])
        for names, opts in self.parser_options.items():
            self.parser.add_option(*names, **opts)
        (self.options, self.args) = self.parser.parse_args()

        if self.options.info:
            self.print_metadata()
            sys.exit(0)

        # XXX should use action='append' instead of rolling our own
        try:
            self.addons = self.options.addons.split(',')
        except:
            self.addons = []

    def get_metadata_from_egg(self):
        import pkg_resources
        ret = {}
        dist = pkg_resources.get_distribution(self.module)
        if dist.has_metadata("PKG-INFO"):
            for line in dist.get_metadata_lines("PKG-INFO"):
                key, value = line.split(':', 1)
                ret[key] = value
        if dist.has_metadata("requires.txt"):
            ret["Dependencies"] = "\n" + dist.get_metadata("requires.txt")
        return ret

    def print_metadata(self, data=("Name", "Version", "Summary", "Home-page",
                                   "Author", "Author-email", "License", "Platform", "Dependencies")):
        for key in data:
            if key in self.metadata:
                print key + ": " + self.metadata[key]

    def create_runner(self):
        """ Get the runner object """
        runner = self.get_runner(binary=self.options.binary)
        profile = self.get_profile(binary=runner.binary,
                                   profile=self.options.profile,
                                   addons=self.addons)
        runner.profile = profile
        return runner

    def get_runner(self, binary=None, profile=None):
        """Returns the runner instance for the given command line binary argument
        the profile instance returned from self.get_profile()."""
        return self.runner_class(binary, profile)

    def get_profile(self, binary=None, profile=None, addons=None, preferences=None):
        """Returns the profile instance for the given command line arguments."""
        addons = addons or []
        preferences = preferences or {}
        return self.profile_class(binary, profile, addons, preferences)

    def run(self):
        runner = self.create_runner()
        self.start(runner)
        runner.profile.cleanup()

    def start(self, runner):
        """Starts the runner and waits for Firefox to exitor Keyboard Interrupt.
        Shoule be overwritten to provide custom running of the runner instance."""
        runner.start()
        print 'Started:', ' '.join(runner.command)
        try:
            runner.wait()
        except KeyboardInterrupt:
            runner.stop()

def cli():
    CLI().run()

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

import os
import sys
import copy
import tempfile
import signal
import commands
import zipfile
import optparse
import killableprocess
import subprocess
import platform
import shutil
from StringIO import StringIO
from xml.dom import minidom

from distutils import dir_util
from time import sleep

# conditional (version-dependent) imports
try:
    import simplejson
except ImportError:
    import json as simplejson

import logging
logger = logging.getLogger(__name__)

# Use dir_util for copy/rm operations because shutil is all kinds of broken
copytree = dir_util.copy_tree
rmtree = dir_util.remove_tree

def findInPath(fileName, path=os.environ['PATH']):
    dirs = path.split(os.pathsep)
    for dir in dirs:
        if os.path.isfile(os.path.join(dir, fileName)):
            return os.path.join(dir, fileName)
        if os.name == 'nt' or sys.platform == 'cygwin':
            if os.path.isfile(os.path.join(dir, fileName + ".exe")):
                return os.path.join(dir, fileName + ".exe")
    return None

stdout = sys.stdout
stderr = sys.stderr
stdin = sys.stdin

def run_command(cmd, env=None, **kwargs):
    """Run the given command in killable process."""
    killable_kwargs = {'stdout':stdout ,'stderr':stderr, 'stdin':stdin}
    killable_kwargs.update(kwargs)

    if sys.platform != "win32":
        return killableprocess.Popen(cmd, preexec_fn=lambda : os.setpgid(0, 0),
                                     env=env, **killable_kwargs)
    else:
        return killableprocess.Popen(cmd, env=env, **killable_kwargs)

def getoutput(l):
    tmp = tempfile.mktemp()
    x = open(tmp, 'w')
    subprocess.call(l, stdout=x, stderr=x)
    x.close(); x = open(tmp, 'r')
    r = x.read() ; x.close()
    os.remove(tmp)
    return r

def get_pids(name, minimun_pid=0):
    """Get all the pids matching name, exclude any pids below minimum_pid."""
    if os.name == 'nt' or sys.platform == 'cygwin':
        import wpk

        pids = wpk.get_pids(name)

    else:
        data = getoutput(['ps', 'ax']).splitlines()
        pids = [int(line.split()[0]) for line in data if line.find(name) is not -1]

    matching_pids = [m for m in pids if m > minimun_pid]
    return matching_pids

def makedirs(name):

    head, tail = os.path.split(name)
    if not tail:
        head, tail = os.path.split(head)
    if head and tail and not os.path.exists(head):
        try:
            makedirs(head)
        except OSError, e:
            pass
        if tail == os.curdir:           # xxx/newdir/. exists if xxx/newdir exists
            return
    try:
        os.mkdir(name)
    except:
        pass

# addon_details() copied from mozprofile
def addon_details(install_rdf_fh):
    """
    returns a dictionary of details about the addon
    - addon_path : path to the addon directory
    Returns:
    {'id':      u'rainbow@colors.org', # id of the addon
     'version': u'1.4',                # version of the addon
     'name':    u'Rainbow',            # name of the addon
     'unpack': # whether to unpack the addon
    """

    details = {
        'id': None,
        'unpack': False,
        'name': None,
        'version': None
    }

    def get_namespace_id(doc, url):
        attributes = doc.documentElement.attributes
        namespace = ""
        for i in range(attributes.length):
            if attributes.item(i).value == url:
                if ":" in attributes.item(i).name:
                    # If the namespace is not the default one remove 'xlmns:'
                    namespace = attributes.item(i).name.split(':')[1] + ":"
                    break
        return namespace

    def get_text(element):
        """Retrieve the text value of a given node"""
        rc = []
        for node in element.childNodes:
            if node.nodeType == node.TEXT_NODE:
                rc.append(node.data)
        return ''.join(rc).strip()

    doc = minidom.parse(install_rdf_fh)

    # Get the namespaces abbreviations
    em = get_namespace_id(doc, "http://www.mozilla.org/2004/em-rdf#")
    rdf = get_namespace_id(doc, "http://www.w3.org/1999/02/22-rdf-syntax-ns#")

    description = doc.getElementsByTagName(rdf + "Description").item(0)
    for node in description.childNodes:
        # Remove the namespace prefix from the tag for comparison
        entry = node.nodeName.replace(em, "")
        if entry in details.keys():
            details.update({ entry: get_text(node) })

    # turn unpack into a true/false value
    if isinstance(details['unpack'], basestring):
        details['unpack'] = details['unpack'].lower() == 'true'

    return details

class Profile(object):
    """Handles all operations regarding profile. Created new profiles, installs extensions,
    sets preferences and handles cleanup."""

    def __init__(self, binary=None, profile=None, addons=None,
                 preferences=None):

        self.binary = binary

        self.create_new = not(bool(profile))
        if profile:
            self.profile = profile
        else:
            self.profile = self.create_new_profile(self.binary)

        self.addons_installed = []
        self.addons = addons or []

        ### set preferences from class preferences
        preferences = preferences or {}
        if hasattr(self.__class__, 'preferences'):
            self.preferences = self.__class__.preferences.copy()
        else:
            self.preferences = {}
        self.preferences.update(preferences)

        for addon in self.addons:
            self.install_addon(addon)

        self.set_preferences(self.preferences)

    def create_new_profile(self, binary):
        """Create a new clean profile in tmp which is a simple empty folder"""
        profile = tempfile.mkdtemp(suffix='.mozrunner')
        return profile

    def unpack_addon(self, xpi_zipfile, addon_path):
        for name in xpi_zipfile.namelist():
            if name.endswith('/'):
                makedirs(os.path.join(addon_path, name))
            else:
                if not os.path.isdir(os.path.dirname(os.path.join(addon_path, name))):
                    makedirs(os.path.dirname(os.path.join(addon_path, name)))
                data = xpi_zipfile.read(name)
                f = open(os.path.join(addon_path, name), 'wb')
                f.write(data) ; f.close()
                zi = xpi_zipfile.getinfo(name)
                os.chmod(os.path.join(addon_path,name), (zi.external_attr>>16))

    def install_addon(self, path):
        """Installs the given addon or directory of addons in the profile."""

        extensions_path = os.path.join(self.profile, 'extensions')
        if not os.path.exists(extensions_path):
            os.makedirs(extensions_path)

        addons = [path]
        if not path.endswith('.xpi') and not os.path.exists(os.path.join(path, 'install.rdf')):
            addons = [os.path.join(path, x) for x in os.listdir(path)]

        for addon in addons:
            if addon.endswith('.xpi'):
                xpi_zipfile = zipfile.ZipFile(addon, "r")
                details = addon_details(StringIO(xpi_zipfile.read('install.rdf')))
                addon_path = os.path.join(extensions_path, details["id"])
                if details.get("unpack", True):
                    self.unpack_addon(xpi_zipfile, addon_path)
                    self.addons_installed.append(addon_path)
                else:
                    shutil.copy(addon, addon_path + '.xpi')
            else:
                # it's already unpacked, but we need to extract the id so we
                # can copy it
                details = addon_details(open(os.path.join(addon, "install.rdf"), "rb"))
                addon_path = os.path.join(extensions_path, details["id"])
                shutil.copytree(addon, addon_path, symlinks=True)

    def set_preferences(self, preferences):
        """Adds preferences dict to profile preferences"""
        prefs_file = os.path.join(self.profile, 'user.js')
        # Ensure that the file exists first otherwise create an empty file
        if os.path.isfile(prefs_file):
            f = open(prefs_file, 'a+')
        else:
            f = open(prefs_file, 'w')

        f.write('\n#MozRunner Prefs Start\n')

        pref_lines = ['user_pref(%s, %s);' %
                      (simplejson.dumps(k), simplejson.dumps(v) ) for k, v in
                       preferences.items()]
        for line in pref_lines:
            f.write(line+'\n')
        f.write('#MozRunner Prefs End\n')
        f.flush() ; f.close()

    def pop_preferences(self):
        """
        pop the last set of preferences added
        returns True if popped
        """

        # our magic markers
        delimeters = ('#MozRunner Prefs Start', '#MozRunner Prefs End')

        lines = file(os.path.join(self.profile, 'user.js')).read().splitlines()
        def last_index(_list, value):
            """
            returns the last index of an item;
            this should actually be part of python code but it isn't
            """
            for index in reversed(range(len(_list))):
                if _list[index] == value:
                    return index
        s = last_index(lines, delimeters[0])
        e = last_index(lines, delimeters[1])

        # ensure both markers are found
        if s is None:
            assert e is None, '%s found without %s' % (delimeters[1], delimeters[0])
            return False # no preferences found
        elif e is None:
            assert e is None, '%s found without %s' % (delimeters[0], delimeters[1])

        # ensure the markers are in the proper order
        assert e > s, '%s found at %s, while %s found at %s' % (delimeters[1], e, delimeters[0], s)

        # write the prefs
        cleaned_prefs = '\n'.join(lines[:s] + lines[e+1:])
        f = file(os.path.join(self.profile, 'user.js'), 'w')
        f.write(cleaned_prefs)
        f.close()
        return True

    def clean_preferences(self):
        """Removed preferences added by mozrunner."""
        while True:
            if not self.pop_preferences():
                break

    def clean_addons(self):
        """Cleans up addons in the profile."""
        for addon in self.addons_installed:
            if os.path.isdir(addon):
                rmtree(addon)

    def cleanup(self):
        """Cleanup operations on the profile."""
        def oncleanup_error(function, path, excinfo):
            #TODO: How should we handle this?
            print "Error Cleaning up: " + str(excinfo[1])
        if self.create_new:
            shutil.rmtree(self.profile, False, oncleanup_error)
        else:
            self.clean_preferences()
            self.clean_addons()

class FirefoxProfile(Profile):
    """Specialized Profile subclass for Firefox"""
    preferences = {# Don't automatically update the application
                   'app.update.enabled' : False,
                   # Don't restore the last open set of tabs if the browser has crashed
                   'browser.sessionstore.resume_from_crash': False,
                   # Don't check for the default web browser
                   'browser.shell.checkDefaultBrowser' : False,
                   # Don't warn on exit when multiple tabs are open
                   'browser.tabs.warnOnClose' : False,
                   # Don't warn when exiting the browser
                   'browser.warnOnQuit': False,
                   # Only install add-ons from the profile and the app folder
                   'extensions.enabledScopes' : 5,
                   # Don't automatically update add-ons
                   'extensions.update.enabled'    : False,
                   # Don't open a dialog to show available add-on updates
                   'extensions.update.notifyUser' : False,
                   }

    # The possible names of application bundles on Mac OS X, in order of
    # preference from most to least preferred.
    # Note: Nightly is obsolete, as it has been renamed to FirefoxNightly,
    # but it will still be present if users update an older nightly build
    # via the app update service.
    bundle_names = ['Firefox', 'FirefoxNightly', 'Nightly']

    # The possible names of binaries, in order of preference from most to least
    # preferred.
    @property
    def names(self):
        if sys.platform == 'darwin':
            return ['firefox', 'nightly', 'shiretoko']
        if (sys.platform == 'linux2') or (sys.platform in ('sunos5', 'solaris')):
            return ['firefox', 'mozilla-firefox', 'iceweasel']
        if os.name == 'nt' or sys.platform == 'cygwin':
            return ['firefox']

class ThunderbirdProfile(Profile):
    preferences = {'extensions.update.enabled'    : False,
                   'extensions.update.notifyUser' : False,
                   'browser.shell.checkDefaultBrowser' : False,
                   'browser.tabs.warnOnClose' : False,
                   'browser.warnOnQuit': False,
                   'browser.sessionstore.resume_from_crash': False,
                   }

    # The possible names of application bundles on Mac OS X, in order of
    # preference from most to least preferred.
    bundle_names = ["Thunderbird", "Shredder"]

    # The possible names of binaries, in order of preference from most to least
    # preferred.
    names = ["thunderbird", "shredder"]

class Runner(object):
    """Handles all running operations. Finds bins, runs and kills the process."""

    def __init__(self, binary=None, profile=None, cmdargs=[], env=None,
                 kp_kwargs={}):
        if binary is None:
            self.binary = self.find_binary()
        elif sys.platform == 'darwin' and binary.find('Contents/MacOS/') == -1:
            self.binary = os.path.join(binary, 'Contents/MacOS/%s-bin' % self.names[0])
        else:
            self.binary = binary

        if not os.path.exists(self.binary):
            raise Exception("Binary path does not exist "+self.binary)

        if sys.platform == 'linux2' and self.binary.endswith('-bin'):
            dirname = os.path.dirname(self.binary)
            if os.environ.get('LD_LIBRARY_PATH', None):
                os.environ['LD_LIBRARY_PATH'] = '%s:%s' % (os.environ['LD_LIBRARY_PATH'], dirname)
            else:
                os.environ['LD_LIBRARY_PATH'] = dirname

        # Disable the crash reporter by default
        os.environ['MOZ_CRASHREPORTER_NO_REPORT'] = '1'

        self.profile = profile

        self.cmdargs = cmdargs
        if env is None:
            self.env = copy.copy(os.environ)
            self.env.update({'MOZ_NO_REMOTE':"1",})
        else:
            self.env = env
        self.kp_kwargs = kp_kwargs or {}

    def find_binary(self):
        """Finds the binary for self.names if one was not provided."""
        binary = None
        if sys.platform in ('linux2', 'sunos5', 'solaris') \
                or sys.platform.startswith('freebsd'):
            for name in reversed(self.names):
                binary = findInPath(name)
        elif os.name == 'nt' or sys.platform == 'cygwin':

            # find the default executable from the windows registry
            try:
                import _winreg
            except ImportError:
                pass
            else:
                sam_flags = [0]
                # KEY_WOW64_32KEY etc only appeared in 2.6+, but that's OK as
                # only 2.6+ has functioning 64bit builds.
                if hasattr(_winreg, "KEY_WOW64_32KEY"):
                    if "64 bit" in sys.version:
                        # a 64bit Python should also look in the 32bit registry
                        sam_flags.append(_winreg.KEY_WOW64_32KEY)
                    else:
                        # possibly a 32bit Python on 64bit Windows, so look in
                        # the 64bit registry incase there is a 64bit app.
                        sam_flags.append(_winreg.KEY_WOW64_64KEY)
                for sam_flag in sam_flags:
                    try:
                        # assumes self.app_name is defined, as it should be for
                        # implementors
                        keyname = r"Software\Mozilla\Mozilla %s" % self.app_name
                        sam = _winreg.KEY_READ | sam_flag
                        app_key = _winreg.OpenKey(_winreg.HKEY_LOCAL_MACHINE, keyname, 0, sam)
                        version, _type = _winreg.QueryValueEx(app_key, "CurrentVersion")
                        version_key = _winreg.OpenKey(app_key, version + r"\Main")
                        path, _ = _winreg.QueryValueEx(version_key, "PathToExe")
                        return path
                    except _winreg.error:
                        pass

            # search for the binary in the path
            for name in reversed(self.names):
                binary = findInPath(name)
                if sys.platform == 'cygwin':
                    program_files = os.environ['PROGRAMFILES']
                else:
                    program_files = os.environ['ProgramFiles']

                if binary is None:
                    for bin in [(program_files, 'Mozilla Firefox', 'firefox.exe'),
                                (os.environ.get("ProgramFiles(x86)"),'Mozilla Firefox', 'firefox.exe'),
                                (program_files, 'Nightly', 'firefox.exe'),
                                (os.environ.get("ProgramFiles(x86)"),'Nightly', 'firefox.exe'),
                                (program_files, 'Aurora', 'firefox.exe'),
                                (os.environ.get("ProgramFiles(x86)"),'Aurora', 'firefox.exe')
                                ]:
                        path = os.path.join(*bin)
                        if os.path.isfile(path):
                            binary = path
                            break
        elif sys.platform == 'darwin':
            for bundle_name in self.bundle_names:
                # Look for the application bundle in the user's home directory
                # or the system-wide /Applications directory.  If we don't find
                # it in one of those locations, we move on to the next possible
                # bundle name.
                appdir = os.path.join("~/Applications/%s.app" % bundle_name)
                if not os.path.isdir(appdir):
                    appdir = "/Applications/%s.app" % bundle_name
                if not os.path.isdir(appdir):
                    continue

                # Look for a binary with any of the possible binary names
                # inside the application bundle.
                for binname in self.names:
                    binpath = os.path.join(appdir,
                                           "Contents/MacOS/%s-bin" % binname)
                    if (os.path.isfile(binpath)):
                        binary = binpath
                        break

                if binary:
                    break

        if binary is None:
            raise Exception('Mozrunner could not locate your binary, you will need to set it.')
        return binary

    @property
    def command(self):
        """Returns the command list to run."""
        cmd = [self.binary, '-profile', self.profile.profile]
        # On i386 OS X machines, i386+x86_64 universal binaries need to be told
        # to run as i386 binaries.  If we're not running a i386+x86_64 universal
        # binary, then this command modification is harmless.
        if sys.platform == 'darwin':
            if hasattr(platform, 'architecture') and platform.architecture()[0] == '32bit':
                cmd = ['arch', '-i386'] + cmd
        return cmd

    def get_repositoryInfo(self):
        """Read repository information from application.ini and platform.ini."""
        import ConfigParser

        config = ConfigParser.RawConfigParser()
        dirname = os.path.dirname(self.binary)
        repository = { }

        for entry in [['application', 'App'], ['platform', 'Build']]:
            (file, section) = entry
            config.read(os.path.join(dirname, '%s.ini' % file))

            for entry in [['SourceRepository', 'repository'], ['SourceStamp', 'changeset']]:
                (key, id) = entry

                try:
                    repository['%s_%s' % (file, id)] = config.get(section, key);
                except:
                    repository['%s_%s' % (file, id)] = None

        return repository

    def start(self):
        """Run self.command in the proper environment."""
        if self.profile is None:
            self.profile = self.profile_class()
        self.process_handler = run_command(self.command+self.cmdargs, self.env, **self.kp_kwargs)

    def wait(self, timeout=None):
        """Wait for the browser to exit."""
        self.process_handler.wait(timeout=timeout)

        if sys.platform != 'win32':
            for name in self.names:
                for pid in get_pids(name, self.process_handler.pid):
                    self.process_handler.pid = pid
                    self.process_handler.wait(timeout=timeout)

    def kill(self, kill_signal=signal.SIGTERM):
        """Kill the browser"""
        if sys.platform != 'win32':
            self.process_handler.kill()
            for name in self.names:
                for pid in get_pids(name, self.process_handler.pid):
                    self.process_handler.pid = pid
                    self.process_handler.kill()
        else:
            try:
                self.process_handler.kill(group=True)
                # On windows, it sometimes behooves one to wait for dust to settle
                # after killing processes.  Let's try that.
                # TODO: Bug 640047 is invesitgating the correct way to handle this case
                self.process_handler.wait(timeout=10)
            except Exception, e:
                logger.error('Cannot kill process, '+type(e).__name__+' '+e.message)

    def stop(self):
        self.kill()

class FirefoxRunner(Runner):
    """Specialized Runner subclass for running Firefox."""

    app_name = 'Firefox'
    profile_class = FirefoxProfile

    # The possible names of application bundles on Mac OS X, in order of
    # preference from most to least preferred.
    # Note: Nightly is obsolete, as it has been renamed to FirefoxNightly,
    # but it will still be present if users update an older nightly build
    # only via the app update service.
    bundle_names = ['Firefox', 'FirefoxNightly', 'Nightly']

    @property
    def names(self):
        if sys.platform == 'darwin':
            return ['firefox', 'nightly', 'shiretoko']
        if sys.platform in ('linux2', 'sunos5', 'solaris') \
                or sys.platform.startswith('freebsd'):
            return ['firefox', 'mozilla-firefox', 'iceweasel']
        if os.name == 'nt' or sys.platform == 'cygwin':
            return ['firefox']

class ThunderbirdRunner(Runner):
    """Specialized Runner subclass for running Thunderbird"""

    app_name = 'Thunderbird'
    profile_class = ThunderbirdProfile

    # The possible names of application bundles on Mac OS X, in order of
    # preference from most to least preferred.
    bundle_names = ["Thunderbird", "Shredder"]

    # The possible names of binaries, in order of preference from most to least
    # preferred.
    names = ["thunderbird", "shredder"]

class CLI(object):
    """Command line interface."""

    runner_class = FirefoxRunner
    profile_class = FirefoxProfile
    module = "mozrunner"

    parser_options = {("-b", "--binary",): dict(dest="binary", help="Binary path.",
                                                metavar=None, default=None),
                      ('-p', "--profile",): dict(dest="profile", help="Profile path.",
                                                 metavar=None, default=None),
                      ('-a', "--addons",): dict(dest="addons",
                                                help="Addons paths to install.",
                                                metavar=None, default=None),
                      ("--info",): dict(dest="info", default=False,
                                        action="store_true",
                                        help="Print module information")
                     }

    def __init__(self):
        """ Setup command line parser and parse arguments """
        self.metadata = self.get_metadata_from_egg()
        self.parser = optparse.OptionParser(version="%prog " + self.metadata["Version"])
        for names, opts in self.parser_options.items():
            self.parser.add_option(*names, **opts)
        (self.options, self.args) = self.parser.parse_args()

        if self.options.info:
            self.print_metadata()
            sys.exit(0)

        # XXX should use action='append' instead of rolling our own
        try:
            self.addons = self.options.addons.split(',')
        except:
            self.addons = []

    def get_metadata_from_egg(self):
        import pkg_resources
        ret = {}
        dist = pkg_resources.get_distribution(self.module)
        if dist.has_metadata("PKG-INFO"):
            for line in dist.get_metadata_lines("PKG-INFO"):
                key, value = line.split(':', 1)
                ret[key] = value
        if dist.has_metadata("requires.txt"):
            ret["Dependencies"] = "\n" + dist.get_metadata("requires.txt")
        return ret

    def print_metadata(self, data=("Name", "Version", "Summary", "Home-page",
                                   "Author", "Author-email", "License", "Platform", "Dependencies")):
        for key in data:
            if key in self.metadata:
                print key + ": " + self.metadata[key]

    def create_runner(self):
        """ Get the runner object """
        runner = self.get_runner(binary=self.options.binary)
        profile = self.get_profile(binary=runner.binary,
                                   profile=self.options.profile,
                                   addons=self.addons)
        runner.profile = profile
        return runner

    def get_runner(self, binary=None, profile=None):
        """Returns the runner instance for the given command line binary argument
        the profile instance returned from self.get_profile()."""
        return self.runner_class(binary, profile)

    def get_profile(self, binary=None, profile=None, addons=None, preferences=None):
        """Returns the profile instance for the given command line arguments."""
        addons = addons or []
        preferences = preferences or {}
        return self.profile_class(binary, profile, addons, preferences)

    def run(self):
        runner = self.create_runner()
        self.start(runner)
        runner.profile.cleanup()

    def start(self, runner):
        """Starts the runner and waits for Firefox to exitor Keyboard Interrupt.
        Shoule be overwritten to provide custom running of the runner instance."""
        runner.start()
        print 'Started:', ' '.join(runner.command)
        try:
            runner.wait()
        except KeyboardInterrupt:
            runner.stop()

def cli():
    CLI().run()


Rical remodel

class SourceParser # :nodoc:
  # == SourceParser::Ical
  #
  # Reads iCalendar events.
  #
  # Example:
  #   abstract_events = SourceParser::Ical.to_abstract_events('http://upcoming.yahoo.com/calendar/v2/event/349225')
  #
  # Sample sources:
  #   http://upcoming.yahoo.com/calendar/v2/event/349225
  #   webcal://appendix.23ae.com/calendars/AlternateHolidays.ics
  #   http://appendix.23ae.com/calendars/AlternateHolidays.ics
  class Ical < Base
    label :iCalendar

    # Override Base::read_url to handle "webcal" scheme addresses.
    def self.read_url(url)
      super(url.gsub(/^webcal:/, 'http:'))
    end

    # Helper to set the start and end dates correctly depending on whether it's a floating or fixed timezone
    def self.dates_for_tz(component, event)
      if component.dtstart_property.tzid.nil?
        event.start_time  = Time.parse(component.dtstart_property.value)
        if component.dtend_property.nil?
          if component.duration
            event.end_time = component.duration_property.add_to_date_time_value(event.start_time)
          else
            event.end_time = event.start_time
          end
        else
          event.end_time = Time.parse(component.dtend_property.value)
        end
      else
        event.start_time  = component.dtstart
        event.end_time    = component.dtend
      end
    rescue RiCal::InvalidTimezoneIdentifier
      event.start_time = Time.parse(component.dtstart_property.to_s)
      event.end_time = Time.parse(component.dtend_property.to_s)
    end

    CALENDAR_CONTENT_RE    = /^BEGIN:VCALENDAR.*?^END:VCALENDAR/m
    EVENT_CONTENT_RE       = /^BEGIN:VEVENT.*?^END:VEVENT/m
    EVENT_DTSTART_RE       = /^DTSTART.*?:([^\r\n$]+)/m
    EVENT_DTEND_RE         = /^DTEND.*?:([^\r\n$]+)/m
    VENUE_CONTENT_RE       = /^BEGIN:VVENUE$.*?^END:VVENUE$/m
    VENUE_CONTENT_BEGIN_RE = /^BEGIN:VVENUE$/m
    VENUE_CONTENT_END_RE   = /^END:VVENUE$/m
    IS_UPCOMING_RE         = /^PRODID:\W+Upcoming/m

    # Return an Array of AbstractEvent instances extracted from an iCalendar input.
    #
    # Options:
    # * :url -- URL of iCalendar data to import
    # * :content -- String of iCalendar data to import
    # * :skip_old -- Should old events be skipped? Default is true.
    def self.to_abstract_events(opts={})
      # Skip old events by default

      opts[:skip_old] = true unless opts[:skip_old] == false
      cutoff = Time.now.yesterday

      content = content_for(opts).gsub(/\r\n/, "\n")

      # Provide special handling for Upcoming's broken implementation of iCalendar
      if content.match(IS_UPCOMING_RE)
        # Strip out superflous self-referential Upcoming link
        content.sub!(%r{\s*\[\s*Full details at http://upcoming.yahoo.com/event/\d+/?\s*\]\s*}m, '')

        # Fix newlines in DESCRIPTION, replace them with escaped '\n' strings.
        matches = content.scan(/^(DESCRIPTION:.+?)(?:^\w+[:;])/m)
        matches.each do |match|
          content.sub!(match[0], match[0].strip.gsub(/\n/, '\n')+"\r\n")
        end
      end

      events = []
      content_calendars = RiCal.parse_string(content)
      content_calendars.each do |content_calendar|
        content_calendar.events.each_with_index do |component, index|
          next if opts[:skip_old] && (component.dtend || component.dtstart).to_time < cutoff
          event             = AbstractEvent.new
          event.title       = component.summary
          event.description = component.description
          event.url         = component.url

          SourceParser::Ical.dates_for_tz(component, event)

          content_venues = content_calendar.to_s.scan(VENUE_CONTENT_RE)

          content_venue = \
          begin
            if content_calendar.to_s.match(%r{VALUE=URI:http://upcoming.yahoo.com/})
              # Special handling for Upcoming, where each event maps 1:1 to a venue
              content_venues[index]
            else
              begin
                # finding the event venue id - VVENUE=V0-001-001423875-1@eventful.com
                venue_uid = component.location_property.params["VVENUE"]
                # finding in the content_venues array an item matching the uid
                venue_uid ? content_venues.find{|content_venue| content_venue.match(/^UID:#{venue_uid}$/m)} : nil
              rescue Exception => e
                # Ignore
                RAILS_DEFAULT_LOGGER.info("SourceParser::Ical.to_abstract_events : Failed to parse content_venue for non-Upcoming event -- #{e}")
                nil
              end
            end
          end

          event.location = to_abstract_location(content_venue, :fallback => component.location)
          events << event
        end
      end

      return events
    end

    # Return an AbstractLocation extracted from an iCalendar input.
    #
    # Arguments:
    # * value - String with iCalendar data to parse which contains a Vvenue item.
    #
    # Options:
    # * :fallback - String to use as the title for the location if the +value+ doesn't contain a Vvenue.
    def self.to_abstract_location(value, opts={})
      value = "" if value.nil?
      a = AbstractLocation.new

      # VVENUE entries are considered just Vcards,
      # treating them as such.
      if vcard_content = value.scan(VENUE_CONTENT_RE).first

        vcard_hash = parse_vcard_content(vcard_content)

        a.title          = vcard_hash['NAME']
        a.street_address = vcard_hash['ADDRESS']
        a.locality       = vcard_hash['CITY']
        a.region         = vcard_hash['REGION']
        a.postal_code    = vcard_hash['POSTALCODE']
        a.country        = vcard_hash['COUNTRY']

        a.latitude, a.longitude = vcard_hash['GEO'].split(/;/).map(&:to_f)

        return a
      end

      if opts[:fallback].blank?
        return nil
      else
        a.title = opts[:fallback]
        return a
      end
    end

    # Return a vcard_hash from vcard content.
    #
    # Arguments:
    # * value - String with content
    #
    #

    def self.parse_vcard_content(value)
      vcards = RiCal.parse_string(value)
     #constrain to 1 vcard per vvenue
      vcard = vcards.first
      # this is an interesting call here
      # RiCal export of nonstandard-outside-of-RFC2445
      # VVENUE into lines

      vcard_lines = vcard.export_properties_to(STDOUT)
      # munges vcard lines
      vcard_hash = v_card_munge(vcard_lines)

      return vcard_hash
    end

    # Return a vcard_hash from vcard_lines.
    #
    # Arguments:
    # * value - vcard_lines
    #
    #
    #

    def self.v_card_munge(value)
      vcard_hash = value.mash do |line|
        # predeclare key, value in case no match
        key = ''
        value = ''
        # if line is of the form key:value
        # where the line has at least one colon
        # do a non-greedy capture of chars not colon
        # followed by a promiscuous match of remaining chars
        if line.match(/^([^:]+?):(.*)$/)
          key = $1
          value = $2
          # if the key has a semi-colon, it is, by spec,
          # followed by a meta-qualifier;
          # in all cases, we only want the key and not the qualifier
          # split always at least returns one item
          # which will always be the item we want
          # we only want the first: drop the second on match semi-colon
          key = key.split(';').first
        end
        [key, value]
      end
      return vcard_hash
    end

    #
  end
end

class SourceParser # :nodoc:
  # == SourceParser::Ical
  #
  # Reads iCalendar events.
  #
  # Example:
  #   abstract_events = SourceParser::Ical.to_abstract_events('http://upcoming.yahoo.com/calendar/v2/event/349225')
  #
  # Sample sources:
  #   http://upcoming.yahoo.com/calendar/v2/event/349225
  #   webcal://appendix.23ae.com/calendars/AlternateHolidays.ics
  #   http://appendix.23ae.com/calendars/AlternateHolidays.ics
  class Ical < Base
    label :iCalendar

    # Override Base::read_url to handle "webcal" scheme addresses.
    def self.read_url(url)
      super(url.gsub(/^webcal:/, 'http:'))
    end

    # Helper to set the start and end dates correctly depending on whether it's a floating or fixed timezone
    def self.dates_for_tz(component, event)
      if component.dtstart_property.tzid.nil?
        event.start_time  = Time.parse(component.dtstart_property.value)
        if component.dtend_property.nil?
          if component.duration
            event.end_time = component.duration_property.add_to_date_time_value(event.start_time)
          else
            event.end_time = event.start_time
          end
        else
          event.end_time = Time.parse(component.dtend_property.value)
        end
      else
        event.start_time  = component.dtstart
        event.end_time    = component.dtend
      end
    rescue RiCal::InvalidTimezoneIdentifier
      event.start_time = Time.parse(component.dtstart_property.to_s)
      event.end_time = Time.parse(component.dtend_property.to_s)
    end

    CALENDAR_CONTENT_RE    = /^BEGIN:VCALENDAR.*?^END:VCALENDAR/m
    EVENT_CONTENT_RE       = /^BEGIN:VEVENT.*?^END:VEVENT/m
    EVENT_DTSTART_RE       = /^DTSTART.*?:([^\r\n$]+)/m
    EVENT_DTEND_RE         = /^DTEND.*?:([^\r\n$]+)/m
    VENUE_CONTENT_RE       = /^BEGIN:VVENUE$.*?^END:VVENUE$/m
    VENUE_CONTENT_BEGIN_RE = /^BEGIN:VVENUE$/m
    VENUE_CONTENT_END_RE   = /^END:VVENUE$/m
    IS_UPCOMING_RE         = /^PRODID:\W+Upcoming/m

    # Return an Array of AbstractEvent instances extracted from an iCalendar input.
    #
    # Options:
    # * :url -- URL of iCalendar data to import
    # * :content -- String of iCalendar data to import
    # * :skip_old -- Should old events be skipped? Default is true.
    def self.to_abstract_events(opts={})
      # Skip old events by default

      opts[:skip_old] = true unless opts[:skip_old] == false
      cutoff = Time.now.yesterday

      content = content_for(opts).gsub(/\r\n/, "\n")

      # Provide special handling for Upcoming's broken implementation of iCalendar
      if content.match(IS_UPCOMING_RE)
        # Strip out superflous self-referential Upcoming link
        content.sub!(%r{\s*\[\s*Full details at http://upcoming.yahoo.com/event/\d+/?\s*\]\s*}m, '')

        # Fix newlines in DESCRIPTION, replace them with escaped '\n' strings.
        matches = content.scan(/^(DESCRIPTION:.+?)(?:^\w+[:;])/m)
        matches.each do |match|
          content.sub!(match[0], match[0].strip.gsub(/\n/, '\n')+"\r\n")
        end
      end

      events = []
      content_calendars = RiCal.parse_string(content)
      content_calendars.each do |content_calendar|
        content_calendar.events.each_with_index do |component, index|
          next if opts[:skip_old] && (component.dtend || component.dtstart).to_time < cutoff
          event             = AbstractEvent.new
          event.title       = component.summary
          event.description = component.description
          event.url         = component.url

          SourceParser::Ical.dates_for_tz(component, event)

          content_venues = content_calendar.to_s.scan(VENUE_CONTENT_RE)

          content_venue = \
          begin
            if content_calendar.to_s.match(%r{VALUE=URI:http://upcoming.yahoo.com/})
              # Special handling for Upcoming, where each event maps 1:1 to a venue
              content_venues[index]
            else
              begin
                # finding the event venue id - VVENUE=V0-001-001423875-1@eventful.com
                venue_uid = component.location_property.params["VVENUE"]
                # finding in the content_venues array an item matching the uid
                venue_uid ? content_venues.find{|content_venue| content_venue.match(/^UID:#{venue_uid}$/m)} : nil
              rescue Exception => e
                # Ignore
                RAILS_DEFAULT_LOGGER.info("SourceParser::Ical.to_abstract_events : Failed to parse content_venue for non-Upcoming event -- #{e}")
                nil
              end
            end
          end

          event.location = to_abstract_location(content_venue, :fallback => component.location)
          events << event
        end
      end

      return events
    end

    # Return an AbstractLocation extracted from an iCalendar input.
    #
    # Arguments:
    # * value - String with iCalendar data to parse which contains a Vvenue item.
    #
    # Options:
    # * :fallback - String to use as the title for the location if the +value+ doesn't contain a Vvenue.
    def self.to_abstract_location(value, opts={})
      value = "" if value.nil?
      a = AbstractLocation.new

      # VVENUE entries are considered just Vcards,
      # treating them as such.
      if vcard_content = value.scan(VENUE_CONTENT_RE).first

        vcard_hash = parse_vcard_content(vcard_content)

        a.title          = vcard_hash['NAME']
        a.street_address = vcard_hash['ADDRESS']
        a.locality       = vcard_hash['CITY']
        a.region         = vcard_hash['REGION']
        a.postal_code    = vcard_hash['POSTALCODE']
        a.country        = vcard_hash['COUNTRY']

        a.latitude, a.longitude = vcard_hash['GEO'].split(/;/).map(&:to_f)

        return a
      end

      if opts[:fallback].blank?
        return nil
      else
        a.title = opts[:fallback]
        return a
      end
    end

    # Return a vcard_hash from vcard content.
    #
    # Arguments:
    # * value - String with content
    #
    #

    def self.parse_vcard_content(value)
      vcards = RiCal.parse_string(value)
     #constrain to 1 vcard per vvenue
      vcard = vcards.first
      # this is an interesting call here
      # RiCal export of nonstandard-outside-of-RFC2445
      # VVENUE into lines

      vcard_lines = vcard.export_properties_to(StringIO.new(''))
      # munges vcard lines
      vcard_hash = v_card_munge(vcard_lines)

      return vcard_hash
    end

    # Return a vcard_hash from vcard_lines.
    #
    # Arguments:
    # * value - vcard_lines
    #
    #
    #

    def self.v_card_munge(value)
      vcard_hash = value.mash do |line|
        # predeclare key, value in case no match
        key = ''
        value = ''
        # if line is of the form key:value
        # where the line has at least one colon
        # do a non-greedy capture of chars not colon
        # followed by a promiscuous match of remaining chars
        if line.match(/^([^:]+?):(.*)$/)
          key = $1
          value = $2
          # if the key has a semi-colon, it is, by spec,
          # followed by a meta-qualifier;
          # in all cases, we only want the key and not the qualifier
          # split always at least returns one item
          # which will always be the item we want
          # we only want the first: drop the second on match semi-colon
          key = key.split(';').first
        end
        [key, value]
      end
      return vcard_hash
    end

    #
  end
end