File: pip.rb

package info (click to toggle)
puppet-agent 7.23.0-1
  • links: PTS, VCS
  • area: main
  • in suites: bookworm
  • size: 19,092 kB
  • sloc: ruby: 245,074; sh: 456; makefile: 38; xml: 33
file content (343 lines) | stat: -rw-r--r-- 10,976 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# Puppet package provider for Python's `pip` package management frontend.
# <http://pip.pypa.io/>

require_relative '../../../puppet/util/package/version/pip'
require_relative '../../../puppet/util/package/version/range'
require_relative '../../../puppet/provider/package_targetable'

Puppet::Type.type(:package).provide :pip, :parent => ::Puppet::Provider::Package::Targetable do

  desc "Python packages via `pip`.

  This provider supports the `install_options` attribute, which allows command-line flags to be passed to pip.
  These options should be specified as an array where each element is either a string or a hash."

  has_feature :installable, :uninstallable, :upgradeable, :versionable, :version_ranges, :install_options, :targetable

  PIP_VERSION       = Puppet::Util::Package::Version::Pip
  PIP_VERSION_RANGE = Puppet::Util::Package::Version::Range

  # Override the specificity method to return 1 if pip is not set as default provider
  def self.specificity
    match = default_match
    length = match ? match.length : 0

    return 1 if length == 0

    super
  end

  # Define the default provider package command name when the provider is targetable.
  # Required by Puppet::Provider::Package::Targetable::resource_or_provider_command
  def self.provider_command
    # Ensure pip can upgrade pip, which usually puts pip into a new path /usr/local/bin/pip (compared to /usr/bin/pip)
    self.cmd.map { |c| which(c) }.find { |c| c != nil }
  end

  def self.cmd
    if Puppet::Util::Platform.windows?
      ["pip.exe"]
    else
      ["pip", "pip-python", "pip2", "pip-2"]
    end
  end

  def self.pip_version(command)
    version = nil
    execpipe [quote(command), '--version'] do |process|
      process.collect do |line|
        md = line.strip.match(/^pip (\d+\.\d+\.?\d*).*$/)
        if md
          version = md[1]
          break
        end
      end
    end

    raise Puppet::Error, _("Cannot resolve pip version") unless version

    version
  end

  # Return an array of structured information about every installed package
  # that's managed by `pip` or an empty array if `pip` is not available.
  def self.instances(target_command = nil)
    if target_command
      command = target_command
      self.validate_command(command)
    else
      command = provider_command
    end

    packages = []
    return packages unless command

    command_options = ['freeze']
    command_version = self.pip_version(command)
    if compare_pip_versions(command_version, '8.1.0') >= 0
      command_options << '--all'
    end

    execpipe [quote(command), command_options] do |process|
      process.collect do |line|
        pkg = parse(line)
        next unless pkg
        pkg[:command] = command
        packages << new(pkg)
      end
    end

    # Pip can also upgrade pip, but it's not listed in freeze so need to special case it
    # Pip list would also show pip installed version, but "pip list" doesn't exist for older versions of pip (E.G v1.0)
    # Not needed when "pip freeze --all" is available.
    if compare_pip_versions(command_version, '8.1.0') == -1
      packages << new({:ensure => command_version, :name => File.basename(command), :provider => name, :command => command})
    end

    packages
  end

  # Parse lines of output from `pip freeze`, which are structured as:
  # _package_==_version_ or _package_===_version_
  def self.parse(line)
    if line.chomp =~ /^([^=]+)===?([^=]+)$/
      {:ensure => $2, :name => $1, :provider => name}
    end
  end

  # Return structured information about a particular package or `nil`
  # if the package is not installed or `pip` itself is not available.
  def query
    command = resource_or_provider_command
    self.class.validate_command(command)

    self.class.instances(command).each do |pkg|
      return pkg.properties if @resource[:name].casecmp(pkg.name).zero?
    end
    return nil
  end

  # Return latest version available for current package
  def latest
    command = resource_or_provider_command
    self.class.validate_command(command)

    command_version = self.class.pip_version(command)
    if self.class.compare_pip_versions(command_version, '1.5.4') == -1
      available_versions_with_old_pip.last
    else
      available_versions_with_new_pip(command_version).last
    end
  end

  def self.compare_pip_versions(x, y)
    begin
      Puppet::Util::Package::Version::Pip.compare(x, y)
    rescue PIP_VERSION::ValidationFailure => ex
      Puppet.debug("Cannot compare #{x} and #{y}. #{ex.message} Falling through default comparison mechanism.")
      Puppet::Util::Package.versioncmp(x, y)
    end
  end

  # Use pip CLI to look up versions from PyPI repositories,
  # honoring local pip config such as custom repositories.
  def available_versions
    command = resource_or_provider_command
    self.class.validate_command(command)

    command_version = self.class.pip_version(command)
    if self.class.compare_pip_versions(command_version, '1.5.4') == -1
      available_versions_with_old_pip
    else
      available_versions_with_new_pip(command_version)
    end
  end

  def available_versions_with_new_pip(command_version)
    command = resource_or_provider_command
    self.class.validate_command(command)

    command_and_options = [self.class.quote(command), 'install', "#{@resource[:name]}==versionplease"]
    extra_arg = list_extra_flags(command_version)
    command_and_options << extra_arg if extra_arg
    command_and_options << install_options if @resource[:install_options]
    execpipe command_and_options do |process|
      process.collect do |line|
        # PIP OUTPUT: Could not find a version that satisfies the requirement example==versionplease (from versions: 1.2.3, 4.5.6)
        if line =~ /from versions: (.+)\)/
          versionList = $1.split(', ').sort do |x,y|
            self.class.compare_pip_versions(x, y)
          end
          return versionList
        end
      end
    end
    []
  end

  def available_versions_with_old_pip
    command = resource_or_provider_command
    self.class.validate_command(command)

    Dir.mktmpdir("puppet_pip") do |dir|
      command_and_options = [self.class.quote(command), 'install', "#{@resource[:name]}", '-d', "#{dir}", '-v']
      command_and_options << install_options if @resource[:install_options]
      execpipe command_and_options do |process|
        process.collect do |line|
          # PIP OUTPUT: Using version 0.10.1 (newest of versions: 1.2.3, 4.5.6)
          if line =~ /Using version .+? \(newest of versions: (.+?)\)/
            versionList = $1.split(', ').sort do |x,y|
              self.class.compare_pip_versions(x, y)
            end
            return versionList
          end
        end
      end
      return []
    end
  end

  # Finds the most suitable version available in a given range
  def best_version(should_range)
    included_available_versions = []
    available_versions.each do |version|
      version = PIP_VERSION.parse(version)
      included_available_versions.push(version) if should_range.include?(version)
    end

    included_available_versions.sort!
    return included_available_versions.last unless included_available_versions.empty?

    Puppet.debug("No available version for package #{@resource[:name]} is included in range #{should_range}")
    should_range
  end

  def get_install_command_options()
    should = @resource[:ensure]
    command_options = %w{install -q}
    command_options += install_options if @resource[:install_options]

    if @resource[:source]
      if String === should
        command_options << "#{@resource[:source]}@#{should}#egg=#{@resource[:name]}"
      else
        command_options << "#{@resource[:source]}#egg=#{@resource[:name]}"
      end

      return command_options
    end

    if should == :latest
      command_options << "--upgrade" << @resource[:name]

      return command_options
    end

    unless String === should
      command_options << @resource[:name]

      return command_options
    end

    begin
      should_range = PIP_VERSION_RANGE.parse(should, PIP_VERSION)
    rescue PIP_VERSION_RANGE::ValidationFailure, PIP_VERSION::ValidationFailure
      Puppet.debug("Cannot parse #{should} as a pip version range, falling through.")
      command_options << "#{@resource[:name]}==#{should}"

      return command_options
    end

    if should_range.is_a?(PIP_VERSION_RANGE::Eq)
      command_options << "#{@resource[:name]}==#{should}"

      return command_options
    end

    should = best_version(should_range)

    if should == should_range
      # when no suitable version for the given range was found, let pip handle
      if should.is_a?(PIP_VERSION_RANGE::MinMax)
        command_options << "#{@resource[:name]} #{should.split.join(',')}"
      else
        command_options << "#{@resource[:name]} #{should}"
      end
    else
      command_options << "#{@resource[:name]}==#{should}"
    end

    command_options
  end

  # Install a package.  The ensure parameter may specify installed,
  # latest, a version number, or, in conjunction with the source
  # parameter, an SCM revision.  In that case, the source parameter
  # gives the fully-qualified URL to the repository.
  def install
    command = resource_or_provider_command
    self.class.validate_command(command)

    command_options = get_install_command_options
    execute([command, command_options])
  end

  # Uninstall a package. Uninstall won't work reliably on Debian/Ubuntu unless this issue gets fixed.
  # http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=562544
  def uninstall
    command = resource_or_provider_command
    self.class.validate_command(command)

    command_options = ["uninstall", "-y", "-q", @resource[:name]]

    execute([command, command_options])
  end

  def update
    install
  end

  def install_options
    join_options(@resource[:install_options])
  end

  def insync?(is)
    return false unless is && is != :absent
    begin
      should = @resource[:ensure]
      should_range = PIP_VERSION_RANGE.parse(should, PIP_VERSION)
    rescue PIP_VERSION_RANGE::ValidationFailure, PIP_VERSION::ValidationFailure
      Puppet.debug("Cannot parse #{should} as a pip version range")
      return false
    end

    begin
      is_version = PIP_VERSION.parse(is)
    rescue PIP_VERSION::ValidationFailure
      Puppet.debug("Cannot parse #{is} as a pip version")
      return false
    end

    should_range.include?(is_version)
  end

  # Quoting is required if the path to the pip command contains spaces.
  # Required for execpipe() but not execute(), as execute() already does this.
  def self.quote(path)
    if path.include?(" ")
      "\"#{path}\""
    else
      path
    end
  end

  private

  def list_extra_flags(command_version)
    klass = self.class
    if klass.compare_pip_versions(command_version, '20.2.4') == 1 &&
      klass.compare_pip_versions(command_version, '21.1') == -1
      '--use-deprecated=legacy-resolver'
    end
  end
end