Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
python_dependency.rb 13.16 KiB
require 'requirement'

# We support Python 2.x and 3.x, either brewed or external.
# This requirement locates the correct CPython binary (no PyPy), provides
# support methods like `site_packages`, and writes our sitecustomize.py file.
# In `dependency_collector.rb`, special `:python` and `:python3` shortcuts are
# defined. You can specify a minimum version of the Python that needs to be
# present, but since not every package is ported to 3.x yet,
# `PythonInstalled("2")` is not satisfied by 3.x.
# In a formula that shall provide support for 2.x and 3.x, the idiom is:
# depends_on :python
# depends_on :python3 => :optional # or :recommended
#
# Todo:
# - Allow further options that choose: universal, framework?, brewed?...
class PythonInstalled < Requirement
  attr_reader :min_version
  attr_reader :if3then3
  attr_reader :imports
  attr_accessor :site_packages
  attr_writer :binary # The python.rb formula needs to set the binary

  fatal true  # you can still make Python optional by `depends_on :python => :optional`

  class PythonVersion < Version
    def major
      tokens[0].to_s.to_i  # Python's major.minor are always ints.
    end
    def minor
      tokens[1].to_s.to_i
    end
  end

  def initialize(default_version="2.6", tags=[] )
    tags = [tags].flatten
    # Extract the min_version if given. Default to default_version else
    if /(\d+\.)*\d+/ === tags.first.to_s
      @min_version = PythonVersion.new(tags.shift)
    else
      @min_version = PythonVersion.new(default_version)
    end

    # often used idiom: e.g. sipdir = "share/sip#{python.if3then3}"
    if @min_version.major == 3
      @if3then3 = "3"
    else
      @if3then3 = ""
    end

    # Set name according to the major version.
    # The name is used to generate the options like --without-python3
    @name = "python" + @if3then3

    # Check if any python modules should be importable. We use a hash to store
    # the corresponding name on PyPi "<import_name>" => "<name_on_PyPi>".
    # Example: `depends_on :python => ['enchant' => 'pyenchant']
    @imports = {}
    tags.each do |tag|
      if tag.kind_of? String
        @imports[tag] = tag  # if the module name is the same as the PyPi name
      elsif tag.kind_of? Hash
        @imports.merge!(tag)
      end
    end

    # will be set later by the python_helper, because it needs the
    # formula prefix to set site_packages
    @site_packages = nil

    super tags
  end

  # Note that during `satisfy` we still have the PATH as the user has set.
  # We look for a brewed python or an external Python and store the loc of
  # that binary for later usage. (See Formula#python)
  satisfy :build_env => false do
    @unsatisfied_because = ''
    if binary.nil? || !binary.executable?
      @unsatisfied_because += "No `#{@name}` found in your PATH! Consider to `brew install #{@name}`."
      false
    elsif pypy?
      @unsatisfied_because += "Your #{@name} executable appears to be a PyPy, which is not supported."
      false
    elsif version.major != @min_version.major
      @unsatisfied_because += "No Python #{@min_version.major}.x found in your PATH! --> `brew install #{@name}`?"
      false
    elsif version < @min_version
      @unsatisfied_because += "Python version #{version} is too old (need at least #{@min_version})."
      false
    elsif @min_version.major == 2 && `python -c "import sys; print(sys.version_info[0])"`.strip == "3"
      @unsatisfied_because += "Your `python` points to a Python 3.x. This is not supported."
    else
      @imports.keys.map do |module_name|
        if not importable? module_name
          @unsatisfied_because += "Unsatisfied dependency: #{module_name}\n"
          @unsatisfied_because += "OS X System's " if from_osx?
          @unsatisfied_because += "Brewed " if brewed?
          @unsatisfied_because += "External " unless brewed? || from_osx?
          @unsatisfied_because += "Python cannot `import #{module_name}`. Install with:\n  "
          unless importable? 'pip'
            @unsatisfied_because += "sudo easy_install pip\n  "
          end
          @unsatisfied_because += "pip-#{version.major}.#{version.minor} install #{@imports[module_name]}"
          false
        else
          true
        end
      end.all?  # all given `module_name`s have to be `importable?`
    end
  end

  def importable? module_name
    quiet_system(binary, "-c", "import #{module_name}")
  end

  # The full path to the python or python3 executable, depending on `version`.
  def binary
    @binary ||= begin
      if brewed?
        # If the python is brewed we always prefer it!
        # Note, we don't support homebrew/versions/pythonXX.rb, though.
        Formula.factory(@name).opt_prefix/"bin/python#{@min_version.major}"
      else
        begin
          # Using the ORIGINAL_PATHS here because in superenv, the user
          # installed external Python is not visible otherwise.
          tmp_PATH = ENV['PATH']
          ENV['PATH'] = ORIGINAL_PATHS.join(':')
          which(@name)
        ensure
          ENV['PATH'] = tmp_PATH
        end
      end
    end
  end

  # The python prefix (special cased for a brewed python to point into the opt_prefix)
  def prefix
    if brewed?
      # Homebrew since a long while only supports frameworked python
      HOMEBREW_PREFIX/"opt/#{name}/Frameworks/Python.framework/Versions/#{version.major}.#{version.minor}"
    elsif from_osx?
      # Python on OS X has been stripped off its includes (unless you install the CLT), therefore we use the MacOS.sdk.
      Pathname.new("#{MacOS.sdk_path}/System/Library/Frameworks/Python.framework/Versions/#{version.major}.#{version.minor}")
    else
      # What Python knows about itself
      Pathname.new(`#{binary} -c 'import sys;print(sys.prefix)'`.strip)
    end
  end

  # Get the actual x.y.z version by asking python (or python3 if @min_version>=3)
  def version
    @version ||= PythonVersion.new(`#{binary} -c 'import sys;print(sys.version[:5])'`.strip)
  end

  # python.xy => "python2.7" is often used (and many formulae had this as `which_python`).
  def xy
    "python#{version.major}.#{version.minor}"
  end

  # Homebrew's global site-packages. The local ones (just `site_packages`) are
  # populated by the python_helperg method when the `prefix` of a formula is known.
  def global_site_packages
    HOMEBREW_PREFIX/"lib/#{xy}/site-packages"
  end

  # Dir containing Python.h and others.
  def incdir
    if (from_osx? || brewed?) && framework?
      prefix/"Headers"
    else
      # For all other we use Python's own standard method (works with a non-framework version, too)
      Pathname.new(`#{binary} -c 'from distutils import sysconfig; print(sysconfig.get_python_inc())'`.strip)
    end
  end

  # Dir containing e.g. libpython2.7.dylib
  def libdir
    if brewed? || from_osx?
      if @min_version.major == 3
        prefix/"lib/#{xy}/config-#{version.major}.#{version.minor}m"
      else
        prefix/"lib/#{xy}/config"
      end
    else
      Pathname.new(`#{binary} -c "from distutils import sysconfig; print(sysconfig.get_config_var('LIBPL'))"`.strip)
    end
  end

  # Pkgconfig (pc) files of python
  def pkg_config_path
    if from_osx?
      # No matter if CLT-only or Xcode-only, the pc file is always here on OS X:
      path = Pathname.new("/System/Library/Frameworks/Python.framework/Versions/#{version.major}.#{version.minor}/lib/pkgconfig")
      path if path.exist?
    else
      prefix/"lib/pkgconfig"
    end
  end

  # Is the Python brewed (and linked)?
  def brewed?
    @brewed ||= begin
      require 'formula'
      (Formula.factory(@name).opt_prefix/"bin/#{@name}").executable?
    end
  end

  # Is the python the one from OS X?
  def from_osx?
    @from_osx ||= begin
      p = `#{binary} -c "import sys; print(sys.prefix)"`.strip
      p.start_with?("/System/Library/Frameworks/Python.framework")
    end
  end

  # Is the `python` a PyPy?
  def pypy?
    @pypy ||= !(`#{binary} -c "import sys; print(sys.version)"`.downcase =~ /.*pypy.*/).nil?
  end

  def framework
    # We return the path to Frameworks and not the 'Python.framework', because
    # the latter is (sadly) the same for 2.x and 3.x.
    if prefix.to_s =~ /^(.*\/Frameworks)\/(Python\.framework).*$/
      @framework = $1
    end
  end
  def framework?; not framework.nil? end

  def universal?
    @universal ||= archs_for_command(binary).universal?
  end

  def standard_caveats
    if brewed?
      ""  # empty string, so we can concat this
    else
      <<-EOS.undent
        For non-homebrew #{@name} (#{@min_version.major}.x), you need to amend your PYTHONPATH like so:
          export PYTHONPATH=#{global_site_packages}:$PYTHONPATH
      EOS
    end
  end

  def modify_build_environment
    # Most methods fail if we don't have a binary.
    return false if binary.nil?

    # Write our sitecustomize.py
    file = global_site_packages/"sitecustomize.py"
    ohai "Writing #{file}" if ARGV.verbose? && ARGV.debug?
    [".pyc", ".pyo", ".py"].map{ |f|
      global_site_packages/"sitecustomize#{f}"
    }.each{ |f| f.delete if f.exist? }
    file.write(sitecustomize)

    # For non-system python's we add the opt_prefix/bin of python to the path.
    ENV.prepend 'PATH', binary.dirname, ':' unless from_osx?

    ENV['PYTHONHOME'] = nil  # to avoid fuck-ups.
    ENV['PYTHONPATH'] = global_site_packages.to_s unless brewed?
    # Python respects the ARCHFLAGS var if set. Shall we set them here?
    # ENV['ARCHFLAGS'] = ??? # FIXME
    ENV.append 'CMAKE_INCLUDE_PATH', incdir, ':'
    ENV.append 'PKG_CONFIG_PATH', pkg_config_path, ':' if pkg_config_path
    # We don't set the -F#{framework} here, because if Python 2.x and 3.x are
    # used, `Python.framework` is ambiguous. However, in the `python do` block
    # we can set LDFLAGS+="-F#{framework}" because only one is temporarily set.

    # Udpate distutils.cfg (later we can remove this, but people still have
    # their old brewed pythons and we have to update it here)
    # Todo: If Jack's formula revisions arrive, we can get rid of this here!
    if brewed?
      require 'formula'
      file = Formula.factory(@name).prefix/"Frameworks/Python.framework/Versions/#{version.major}.#{version.minor}/lib/#{xy}/distutils/distutils.cfg"
      ohai "Writing #{file}" if ARGV.verbose? && ARGV.debug?
      file.delete if file.exist?
      file.write <<-EOF.undent
        [global]
        verbose=1
        [install]
        force=1
        prefix=#{HOMEBREW_PREFIX}
      EOF
    end
    true
  end

  def sitecustomize
    <<-EOF.undent
      # This file is created by Homebrew and is executed on each python startup.
      # Don't print from here, or else universe will collapse.
      import sys

      if sys.version_info[0] == #{version.major} and sys.version_info[1] == #{version.minor}:
          if sys.executable.startswith('#{HOMEBREW_PREFIX}/opt/python'):
              # Fix 1)
              #   A setuptools.pth and/or easy-install.pth sitting either in
              #   /Library/Python/2.7/site-packages or in
              #   ~/Library/Python/2.7/site-packages can inject the
              #   /System's Python site-packages. People then report
              #   "OSError: [Errno 13] Permission denied" because pip/easy_install
              #   attempts to install into
              #   /System/Library/Frameworks/Python.framework/Versions/2.7/Extras/lib/python
              #   See: https://github.com/mxcl/homebrew/issues/14712
              # Fix 2)
              #   Remove brewed Python's hard-coded Cellar-site-packages
              sys.path = [ p for p in sys.path
                           if not (p.startswith('/System') or
                                   p.startswith('#{HOMEBREW_PREFIX}/Cellar/python') and p.endswith('site-packages')) ]
              # Fix 3)
              # Set the sys.executable to use the opt_prefix
              sys.executable = '#{HOMEBREW_PREFIX}/opt/#{name}/bin/python#{version.major}.#{version.minor}'
              # Fix 4)
              # Make LINKFORSHARED (and python-config --ldflags) return the
              # full path to the lib (yes, "Python" is actually the lib, not a
              # dir) so that third-party software does not need to add the
              # -F/#{HOMEBREW_PREFIX}/Frameworks switch.
              # Assume Framework style build (default since months in brew)
              try:
                  from _sysconfigdata import build_time_vars
                  build_time_vars['LINKFORSHARED'] = '-u _PyMac_Error #{HOMEBREW_PREFIX}/opt/#{name}/Frameworks/Python.framework/Versions/#{version.major}.#{version.minor}/Python'
              except:
                  pass  # remember: don't print here. Better to fail silently.
          # Fix 5)
          #   For all Pythons of the right major.minor version: Tell about homebrew's
          #   site-packages location. This is needed for Python to parse *.pth.
          import site
          site.addsitedir('#{global_site_packages}')
    EOF
  end

  def message
    @unsatisfied_because
  end

  def <=> other
    version <=> other.version
  end

  def to_s
    binary.to_s
  end

  # Objects of this class are used to represent dependencies on Python and
  # dependencies on Python modules, so the combination of name + imports is
  # enough to identify them uniquely.
  def eql?(other)
    instance_of?(other.class) && name == other.name && imports == other.imports
  end

  def hash
    [name, *imports].hash
  end
end