fpm 1.15.1 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,76 +51,371 @@ class FPM::Package::Python < FPM::Package
51
51
  option "--downcase-dependencies", :flag, "Should the package dependencies " \
52
52
  "be in lowercase?", :default => true
53
53
 
54
- option "--install-bin", "BIN_PATH", "The path to where python scripts " \
55
- "should be installed to."
56
- option "--install-lib", "LIB_PATH", "The path to where python libs " \
54
+ option "--install-bin", "BIN_PATH", "(DEPRECATED, does nothing) The path to where python scripts " \
55
+ "should be installed to." do
56
+ logger.warn("Using deprecated flag --install-bin")
57
+ end
58
+ option "--install-lib", "LIB_PATH", "(DEPRECATED, does nothing) The path to where python libs " \
57
59
  "should be installed to (default depends on your python installation). " \
58
60
  "Want to find out what your target platform is using? Run this: " \
59
61
  "python -c 'from distutils.sysconfig import get_python_lib; " \
60
- "print get_python_lib()'"
61
- option "--install-data", "DATA_PATH", "The path to where data should be " \
62
+ "print get_python_lib()'" do
63
+ logger.warn("Using deprecated flag --install-bin")
64
+ end
65
+
66
+ option "--install-data", "DATA_PATH", "(DEPRECATED, does nothing) The path to where data should be " \
62
67
  "installed to. This is equivalent to 'python setup.py --install-data " \
63
- "DATA_PATH"
64
- option "--dependencies", :flag, "Include requirements defined in setup.py" \
68
+ "DATA_PATH" do
69
+ logger.warn("Using deprecated flag --install-bin")
70
+ end
71
+
72
+ option "--dependencies", :flag, "Include requirements defined by the python package" \
65
73
  " as dependencies.", :default => true
66
74
  option "--obey-requirements-txt", :flag, "Use a requirements.txt file " \
67
75
  "in the top-level directory of the python package for dependency " \
68
76
  "detection.", :default => false
69
- option "--scripts-executable", "PYTHON_EXECUTABLE", "Set custom python " \
77
+ option "--scripts-executable", "PYTHON_EXECUTABLE", "(DEPRECATED) Set custom python " \
70
78
  "interpreter in installing scripts. By default distutils will replace " \
71
79
  "python interpreter in installing scripts (specified by shebang) with " \
72
80
  "current python interpreter (sys.executable). This option is equivalent " \
73
81
  "to appending 'build_scripts --executable PYTHON_EXECUTABLE' arguments " \
74
- "to 'setup.py install' command."
82
+ "to 'setup.py install' command." do
83
+ logger.warn("Using deprecated flag --install-bin")
84
+ end
85
+
75
86
  option "--disable-dependency", "python_package_name",
76
87
  "The python package name to remove from dependency list",
77
88
  :multivalued => true, :attribute_name => :python_disable_dependency,
78
89
  :default => []
79
90
  option "--setup-py-arguments", "setup_py_argument",
80
- "Arbitrary argument(s) to be passed to setup.py",
91
+ "(DEPRECATED) Arbitrary argument(s) to be passed to setup.py",
81
92
  :multivalued => true, :attribute_name => :python_setup_py_arguments,
82
- :default => []
93
+ :default => [] do
94
+ logger.warn("Using deprecated flag --install-bin")
95
+ end
83
96
  option "--internal-pip", :flag,
84
97
  "Use the pip module within python to install modules - aka 'python -m pip'. This is the recommended usage since Python 3.4 (2014) instead of invoking the 'pip' script",
85
98
  :attribute_name => :python_internal_pip,
86
99
  :default => true
100
+
101
+ class PythonMetadata
102
+ require "strscan"
103
+
104
+ class MissingField < StandardError; end
105
+ class UnexpectedContent < StandardError; end
106
+
107
+ # According to https://packaging.python.org/en/latest/specifications/core-metadata/
108
+ # > Core Metadata v2.4 - August 2024
109
+ MULTIPLE_USE = %w(Dynamic Platform Supported-Platform License-File Classifier Requires-Dist Requires-External Project-URL Provides-Extra Provides-Dist Obsoletes-Dist)
110
+
111
+ # METADATA files are described in Python Packaging "Core Metadata"[1] and appear to have roughly RFC822 syntax.
112
+ # [1] https://packaging.python.org/en/latest/specifications/core-metadata/#core-metadata
113
+ def self.parse(input)
114
+ s = StringScanner.new(input)
115
+ headers = {}
116
+
117
+ # Default "Multiple use" fields to empty array instead of nil.
118
+ MULTIPLE_USE.each do |field|
119
+ headers[field] = []
120
+ end
121
+
122
+ while !s.eos? and !s.scan("\n") do
123
+ # Field is non-space up, but excluding the colon
124
+ field = s.scan(/[^\s:]+/)
87
125
 
88
- private
126
+ # Skip colon and following whitespace
127
+ s.scan(/:\s*/)
128
+
129
+ # Value is text until newline, and any following lines if they have leading spaces.
130
+ value = s.scan(/[^\n]+(?:\Z|\n(?:[ \t][^\n]+\n)*)/)
131
+ if value.nil?
132
+ raise "Failed parsing Python package metadata value at field #{field}, char offset #{s.pos}"
133
+ end
134
+ value = value.chomp
135
+
136
+ if MULTIPLE_USE.include?(field)
137
+ raise "Header field should be an array. This is a bug in fpm." if !headers[field].is_a?(Array)
138
+ headers[field] << value
139
+ else
140
+ headers[field] = value
141
+ end
142
+ end # while reading headers
143
+
144
+ # If there's more content beyond the last header, then it's a content body.
145
+ # In Python Metadata >= 2.1, the descriptino can be written in the body.
146
+ if !s.eos?
147
+ if headers["Metadata-Version"].to_f >= 2.1
148
+ # Per Python core-metadata spec:
149
+ # > Changed in version 2.1: This field may be specified in the message body instead.
150
+ #return PythonMetadata.new(headers, s.string[s.pos ...])
151
+ return headers, s.string[s.pos ... ]
152
+ elsif headers["Metadata-Version"].to_f >= 2.0
153
+ # dnspython v1.15.0 has a description body and Metadata-Version 2.0
154
+ # this seems out of spec, but let's accept it anyway.
155
+ return headers, s.string[s.pos ... ]
156
+ else
157
+ raise "After reading METADATA headers, extra data is in the file but was not expected. This may be a bug in fpm."
158
+ end
159
+ end
160
+
161
+ #return PythonMetadata.new(headers)
162
+ return headers, nil # nil means no body in this metadata
163
+ rescue => e
164
+ puts "String scan failed: #{e}"
165
+ puts "Position: #{s.pointer}"
166
+ puts "---"
167
+ puts input
168
+ puts "==="
169
+ puts input[s.pointer...]
170
+ puts "---"
171
+ raise e
172
+ end # self.parse
173
+
174
+ def self.from(input)
175
+ return PythonMetadata.new(*parse(input))
176
+ end
177
+
178
+ # Only focusing on terms fpm may care about
179
+ attr_reader :name, :version, :summary, :description, :keywords, :maintainer, :license, :requires, :homepage
180
+
181
+ FIELD_MAP = {
182
+ :@name => "Name",
183
+ :@version => "Version",
184
+ :@summary => "Summary",
185
+ :@description => "Description",
186
+ :@keywords => "Keywords",
187
+ :@maintainer => "Author-email",
188
+
189
+ # Note: License can also come from the deprecated "License" field
190
+ # This is processed later in this method.
191
+ :@license => "License-Expression",
192
+
193
+ :@requires => "Requires-Dist",
194
+ }
195
+
196
+ REQUIRED_FIELDS = [ "Metadata-Version", "Name", "Version" ]
197
+
198
+ # headers - a Hash containing field-value pairs from headers as read from a python METADATA file.
199
+ # body - optional, a string containing the body text of a METADATA file
200
+ def initialize(headers, body=nil)
201
+ REQUIRED_FIELDS.each do |field|
202
+ if !headers.include?(field)
203
+ raise MissingField, "Missing required Python metadata field, '#{field}'. This might be a bug in the package or in fpm."
204
+ end
205
+ end
206
+
207
+ FIELD_MAP.each do |attr, field|
208
+ if headers.include?(field)
209
+ instance_variable_set(attr, headers.fetch(field))
210
+ end
211
+ end
212
+
213
+ # Do any extra processing on fields to turn them into their expected content.
214
+ process_description(headers, body)
215
+ process_license(headers)
216
+ process_homepage(headers)
217
+ process_maintainer(headers)
218
+ end # def initialize
219
+
220
+ private
221
+ def process_description(headers, body)
222
+ if @description
223
+ # Per python core-metadata spec:
224
+ # > To support empty lines and lines with indentation with respect to the
225
+ # > RFC 822 format, any CRLF character has to be suffixed by 7 spaces
226
+ # > followed by a pipe (“|”) char. As a result, the Description field is
227
+ # > encoded into a folded field that can be interpreted by RFC822 parser [2].
228
+ @description = @description.gsub!(/^ |/, "")
229
+ end
230
+
231
+ if !body.nil?
232
+ if headers["Metadata-Version"].to_f >= 2.1
233
+ # Per Python core-metadata spec:
234
+ # > Changed in version 2.1: [Description] field may be specified in the message body instead.
235
+ #
236
+ # The description is simply the rest of the METADATA file after the headers.
237
+ @description = body
238
+ elsif headers["Metadata-Version"].to_f >= 2.0
239
+ # dnspython v1.15.0 has a description body and Metadata-Version 2.0
240
+ # this seems out of spec, but let's accept it anyway.
241
+ @description = body
242
+ else
243
+ raise UnexpectedContent, "Found a content body in METADATA file, but Metadata-Version(#{headers["Metadata-Version"]}) is below 2.1 and doesn't support this. This may be a bug in fpm or a malformed python package."
244
+ end
245
+
246
+ # What to do if we find a description body but already have a Description field set in the headers?
247
+ if headers.include?("Description")
248
+ raise "Found a description in the body of the python package metadata, but the package already set the Description field. I don't know what to do. This is probably a bug in fpm."
249
+ end
250
+ end
251
+
252
+ # XXX: The description field can be markdown, plain text, or reST.
253
+ # Content type is noted in the "Description-Content-Type" field
254
+ # Should we transform this to plain text?
255
+ end # process_description
256
+
257
+ def process_license(headers)
258
+ # Ignore the "License" field if License-Expression is also present.
259
+ return if headers["Metadata-Version"].to_f >= 2.4 && headers.include?("License-Expression")
260
+
261
+ # Deprecated field, License, as described in python core-metadata:
262
+ # > As of Metadata 2.4, License and License-Expression are mutually exclusive.
263
+ # > If both are specified, tools which parse metadata will disregard License
264
+ # > and PyPI will reject uploads. See PEP 639.
265
+ if headers["License"]
266
+ # Note: This license can be free form text, so it's unclear if it's a great choice.
267
+ # however, the original python metadata License field is quite old/deprecated
268
+ # so maybe nobody uses it anymore?
269
+ @license = headers["License"]
270
+ elsif license_classifier = headers["Classifier"].find { |value| value =~ /^License ::/ }
271
+ # The license could also show up in the "Classifier" header with "License ::" as a prefix.
272
+ @license = license_classifier.sub(/^License ::/, "")
273
+ end # check for deprecated License field
274
+ end # process_license
275
+
276
+ def process_homepage(headers)
277
+ return if headers["Project-URL"].empty?
278
+
279
+ # Create a hash of Project-URL where the label is the key, url the value.
280
+ urls = Hash[*headers["Project-URL"].map do |text|
281
+ label, url = text.split(/, */, 2)
282
+ # Normalize the label by removing punctuation and spaces
283
+ # Reference: https://packaging.python.org/en/latest/specifications/well-known-project-urls/#label-normalization
284
+ # > In plain language: a label is normalized by deleting all ASCII punctuation and whitespace, and then converting the result to lowercase.
285
+ label = label.gsub(/[[:punct:][:space:]]/, "").downcase
286
+ [label, url]
287
+ end.flatten(1)]
288
+
289
+ # Prioritize certain URL labels when choosing the homepage url.
290
+ [ "homepage", "source", "documentation", "releasenotes" ].each do |label|
291
+ if urls.include?(label)
292
+ @homepage = urls[label]
293
+ end
294
+ end
295
+
296
+ # Otherwise, default to the first URL
297
+ @homepage = urls.values.first
298
+ end
299
+
300
+ def process_maintainer(headers)
301
+ # Python metadata supports both "Author-email" and "Maintainer-email"
302
+ # Of the "Maintainer" fields, python core-metadata says:
303
+ # > Note that this field is intended for use when a project is being maintained by someone other than the original author
304
+ #
305
+ # So we should prefer Maintainer-email if it exists, but fall back to Author-email otherwise.
306
+ @maintainer = headers["Maintainer-email"] unless headers["Maintainer-email"].nil?
307
+ end
308
+ end # class PythonMetadata
89
309
 
90
310
  # Input a package.
91
311
  #
92
312
  # The 'package' can be any of:
93
313
  #
94
314
  # * A name of a package on pypi (ie; easy_install some-package)
95
- # * The path to a directory containing setup.py
96
- # * The path to a setup.py
315
+ # * The path to a directory containing setup.py or pyproject.toml
316
+ # * The path to a setup.py or pyproject.toml
317
+ # * The path to a python sdist file ending in .tar.gz
318
+ # * The path to a python wheel file ending in .whl
97
319
  def input(package)
320
+ explore_environment
321
+
98
322
  path_to_package = download_if_necessary(package, version)
99
323
 
324
+ # Expect a setup.py or pyproject.toml if it's a directory.
100
325
  if File.directory?(path_to_package)
101
- setup_py = File.join(path_to_package, "setup.py")
102
- else
103
- setup_py = path_to_package
326
+ if !(File.exist?(File.join(path_to_package, "setup.py")) or File.exist?(File.join(path_to_package, "pyproject.toml")))
327
+ raise FPM::InvalidPackageConfiguration, "The path ('#{path_to_package}') doesn't appear to be a python package directory. I expected either a pyproject.toml or setup.py but found neither."
328
+ end
329
+ end
330
+
331
+ if File.file?(path_to_package)
332
+ if ["setup.py", "pyproject.toml"].include?(File.basename(path_to_package))
333
+ path_to_package = File.dirname(path_to_package)
334
+ end
104
335
  end
105
336
 
106
- if !File.exist?(setup_py)
107
- logger.error("Could not find 'setup.py'", :path => setup_py)
108
- raise "Unable to find python package; tried #{setup_py}"
337
+ if [".tar.gz", ".tgz"].any? { |suffix| path_to_package.end_with?(suffix) }
338
+ # Have pip convert the .tar.gz (source dist?) into a wheel
339
+ logger.debug("Found tarball and assuming it's a python source package.")
340
+ safesystem(*attributes[:python_pip], "wheel", "--no-deps", "-w", build_path, path_to_package)
341
+
342
+ path_to_package = ::Dir.glob(build_path("*.whl")).first
343
+ if path_to_package.nil?
344
+ raise FPM::InvalidPackageConfiguration, "Failed building python package format - fpm tried to build a python wheel, but didn't find the .whl file. This might be a bug in fpm."
345
+ end
346
+ elsif File.directory?(path_to_package)
347
+ logger.debug("Found directory and assuming it's a python source package.")
348
+ safesystem(*attributes[:python_pip], "wheel", "--no-deps", "-w", build_path, path_to_package)
349
+
350
+ if attributes[:python_obey_requirements_txt?]
351
+ reqtxt = File.join(path_to_package, "requirements.txt")
352
+ @requirements_txt = File.read(reqtxt).split("\n") if File.file?(reqtxt)
353
+ end
354
+
355
+ path_to_package = ::Dir.glob(build_path("*.whl")).first
356
+ if path_to_package.nil?
357
+ raise FPM::InvalidPackageConfiguration, "Failed building python package format - fpm tried to build a python wheel, but didn't find the .whl file. This might be a bug in fpm."
358
+ end
359
+
109
360
  end
110
361
 
111
- load_package_info(setup_py)
112
- install_to_staging(setup_py)
362
+ load_package_info(path_to_package)
363
+ install_to_staging(path_to_package)
113
364
  end # def input
114
365
 
366
+ def explore_environment
367
+ if !attributes[:python_bin_given?]
368
+ # If --python-bin isn't set, try to find a good default python executable path, because it might not be "python"
369
+ pythons = [ "python", "python3", "python2" ]
370
+ default_python = pythons.find { |py| program_exists?(py) }
371
+
372
+ if default_python.nil?
373
+ raise FPM::Util::ExecutableNotFound, "Could not find any python interpreter. Tried the following: #{pythons.join(", ")}"
374
+ end
375
+
376
+ logger.info("Setting default python executable", :name => default_python)
377
+ attributes[:python_bin] = default_python
378
+
379
+ if !attributes[:python_package_name_prefix_given?]
380
+ attributes[:python_package_name_prefix] = default_python
381
+ logger.info("Setting package name prefix", :name => default_python)
382
+ end
383
+ end
384
+
385
+ if attributes[:python_internal_pip?]
386
+ # XXX: Should we detect if internal pip is available?
387
+ attributes[:python_pip] = [ attributes[:python_bin], "-m", "pip"]
388
+ end
389
+ end # explore_environment
390
+
391
+
392
+
115
393
  # Download the given package if necessary. If version is given, that version
116
394
  # will be downloaded, otherwise the latest is fetched.
117
395
  def download_if_necessary(package, version=nil)
118
- # TODO(sissel): this should just be a 'download' method, the 'if_necessary'
119
- # part should go elsewhere.
120
396
  path = package
397
+
121
398
  # If it's a path, assume local build.
122
- if File.directory?(path) or (File.exist?(path) and File.basename(path) == "setup.py")
123
- return path
399
+ if File.exist?(path)
400
+ return path if File.directory?(path)
401
+
402
+ basename = File.basename(path)
403
+ return File.dirname(path) if basename == "pyproject.toml"
404
+ return File.dirname(path) if basename == "setup.py"
405
+
406
+ return path if path.end_with?(".tar.gz")
407
+ return path if path.end_with?(".tgz") # amqplib v1.0.2 does this
408
+ return path if path.end_with?(".whl")
409
+ return path if path.end_with?(".zip")
410
+ return path if File.exist?(File.join(path, "setup.py"))
411
+ return path if File.exist?(File.join(path, "pyproject.toml"))
412
+
413
+ raise [
414
+ "Local file doesn't appear to be a supported type for a python package. Expected one of:",
415
+ " - A directory containing setup.py or pyproject.toml",
416
+ " - A file ending in .tar.gz (a python source dist)",
417
+ " - A file ending in .whl (a python wheel)",
418
+ ].join("\n")
124
419
  end
125
420
 
126
421
  logger.info("Trying to download", :package => package)
@@ -134,24 +429,16 @@ class FPM::Package::Python < FPM::Package
134
429
  target = build_path(package)
135
430
  FileUtils.mkdir(target) unless File.directory?(target)
136
431
 
137
- if attributes[:python_internal_pip?]
138
- # XXX: Should we detect if internal pip is available?
139
- attributes[:python_pip] = [ attributes[:python_bin], "-m", "pip"]
140
- end
141
-
142
432
  # attributes[:python_pip] -- expected to be a path
143
433
  if attributes[:python_pip]
144
434
  logger.debug("using pip", :pip => attributes[:python_pip])
145
- # TODO: Support older versions of pip
146
-
147
435
  pip = [attributes[:python_pip]] if pip.is_a?(String)
148
436
  setup_cmd = [
149
437
  *attributes[:python_pip],
150
438
  "download",
151
439
  "--no-clean",
152
440
  "--no-deps",
153
- "--no-binary", ":all:",
154
- "-d", build_path,
441
+ "-d", target,
155
442
  "-i", attributes[:python_pypi],
156
443
  ]
157
444
 
@@ -166,124 +453,105 @@ class FPM::Package::Python < FPM::Package
166
453
 
167
454
  safesystem(*setup_cmd)
168
455
 
169
- # Pip removed the --build flag sometime in 2021, it seems: https://github.com/pypa/pip/issues/8333
170
- # A workaround for pip removing the `--build` flag. Previously, `pip download --build ...` would leave
171
- # behind a directory with the Python package extracted and ready to be used.
172
- # For example, `pip download ... Django` puts `Django-4.0.4.tar.tz` into the build_path directory.
173
- # If we expect `pip` to leave an unknown-named file in the `build_path` directory, let's check for
174
- # a single file and unpack it. I don't know if it will /always/ be a .tar.gz though.
175
- files = ::Dir.glob(File.join(build_path, "*.tar.gz"))
456
+ files = ::Dir.entries(target).filter { |entry| entry =~ /\.(whl|tgz|tar\.gz|zip)$/ }
176
457
  if files.length != 1
177
- raise "Unexpected directory layout after `pip download ...`. This might be an fpm bug? The directory is #{build_path}"
458
+ raise "Unexpected directory layout after `pip download ...`. This might be an fpm bug? The directory contains these files: #{files.inspect}"
178
459
  end
179
-
180
- safesystem("tar", "-zxf", files[0], "-C", target)
460
+ return File.join(target, files.first)
181
461
  else
182
462
  # no pip, use easy_install
183
463
  logger.debug("no pip, defaulting to easy_install", :easy_install => attributes[:python_easyinstall])
184
464
  safesystem(attributes[:python_easyinstall], "-i",
185
465
  attributes[:python_pypi], "--editable", "-U",
186
466
  "--build-directory", target, want_pkg)
467
+ # easy_install will put stuff in @tmpdir/packagename/, so find that:
468
+ # @tmpdir/somepackage/setup.py
469
+ #dirs = ::Dir.glob(File.join(target, "*"))
470
+ files = ::Dir.entries(target).filter { |entry| entry != "." && entry != ".." }
471
+ if dirs.length != 1
472
+ raise "Unexpected directory layout after easy_install. Maybe file a bug? The directory is #{build_path}"
473
+ end
474
+ return dirs.first
187
475
  end
188
-
189
- # easy_install will put stuff in @tmpdir/packagename/, so find that:
190
- # @tmpdir/somepackage/setup.py
191
- dirs = ::Dir.glob(File.join(target, "*"))
192
- if dirs.length != 1
193
- raise "Unexpected directory layout after easy_install. Maybe file a bug? The directory is #{build_path}"
194
- end
195
- return dirs.first
196
476
  end # def download
197
477
 
198
478
  # Load the package information like name, version, dependencies.
199
- def load_package_info(setup_py)
200
- if !attributes[:python_package_prefix].nil?
201
- attributes[:python_package_name_prefix] = attributes[:python_package_prefix]
202
- end
203
-
204
- begin
205
- json_test_code = [
206
- "try:",
207
- " import json",
208
- "except ImportError:",
209
- " import simplejson as json"
210
- ].join("\n")
211
- safesystem("#{attributes[:python_bin]} -c '#{json_test_code}'")
212
- rescue FPM::Util::ProcessFailed => e
213
- logger.error("Your python environment is missing json support (either json or simplejson python module). I cannot continue without this.", :python => attributes[:python_bin], :error => e)
214
- raise FPM::Util::ProcessFailed, "Python (#{attributes[:python_bin]}) is missing simplejson or json modules."
215
- end
479
+ def load_package_info(path)
480
+ if path.end_with?(".whl")
481
+ # XXX: Maybe use rubyzip to parse the .whl (zip) file instead?
482
+ metadata = nil
483
+ execmd(["unzip", "-p", path, "*.dist-info/METADATA"], :stdin => false, :stderr => false) do |stdout|
484
+ metadata = PythonMetadata.from(stdout.read(64<<10))
485
+ end
216
486
 
217
- begin
218
- safesystem("#{attributes[:python_bin]} -c 'import pkg_resources'")
219
- rescue FPM::Util::ProcessFailed => e
220
- logger.error("Your python environment is missing a working setuptools module. I tried to find the 'pkg_resources' module but failed.", :python => attributes[:python_bin], :error => e)
221
- raise FPM::Util::ProcessFailed, "Python (#{attributes[:python_bin]}) is missing pkg_resources module."
487
+ wheeldata = nil
488
+ execmd(["unzip", "-p", path, "*.dist-info/WHEEL"], :stdin => false, :stderr => false) do |stdout|
489
+ wheeldata, _ = PythonMetadata.parse(stdout.read(64<<10))
490
+ end
491
+ else
492
+ raise "Unexpected python package path. This might be an fpm bug? The path is #{path}"
222
493
  end
223
494
 
224
- # Add ./pyfpm/ to the python library path
225
- pylib = File.expand_path(File.dirname(__FILE__))
495
+ self.architecture = wheeldata["Root-Is-Purelib"] == "true" ? "all" : "native"
226
496
 
227
- # chdir to the directory holding setup.py because some python setup.py's assume that you are
228
- # in the same directory.
229
- setup_dir = File.dirname(setup_py)
497
+ self.description = metadata.description unless metadata.description.nil?
498
+ self.license = metadata.license unless metadata.license.nil?
499
+ self.version = metadata.version
500
+ self.url = metadata.homepage unless metadata.homepage.nil?
230
501
 
231
- output = ::Dir.chdir(setup_dir) do
232
- tmp = build_path("metadata.json")
233
- setup_cmd = "env PYTHONPATH=#{pylib}:$PYTHONPATH #{attributes[:python_bin]} " \
234
- "setup.py --command-packages=pyfpm get_metadata --output=#{tmp}"
235
-
236
- if attributes[:python_obey_requirements_txt?]
237
- setup_cmd += " --load-requirements-txt"
238
- end
239
-
240
- # Capture the output, which will be JSON metadata describing this python
241
- # package. See fpm/lib/fpm/package/pyfpm/get_metadata.py for more
242
- # details.
243
- logger.info("fetching package metadata", :setup_cmd => setup_cmd)
244
-
245
- success = safesystem(setup_cmd)
246
- #%x{#{setup_cmd}}
247
- if !success
248
- logger.error("setup.py get_metadata failed", :command => setup_cmd,
249
- :exitcode => $?.exitstatus)
250
- raise "An unexpected error occurred while processing the setup.py file"
251
- end
252
- File.read(tmp)
253
- end
254
- logger.debug("result from `setup.py get_metadata`", :data => output)
255
- metadata = JSON.parse(output)
256
- logger.info("object output of get_metadata", :json => metadata)
257
-
258
- self.architecture = metadata["architecture"]
259
- self.description = metadata["description"]
260
- # Sometimes the license field is multiple lines; do best-effort and just
261
- # use the first line.
262
- if metadata["license"]
263
- self.license = metadata["license"].split(/[\r\n]+/).first
264
- end
265
- self.version = metadata["version"]
266
- self.url = metadata["url"]
502
+ self.name = metadata.name
267
503
 
268
504
  # name prefixing is optional, if enabled, a name 'foo' will become
269
505
  # 'python-foo' (depending on what the python_package_name_prefix is)
270
- if attributes[:python_fix_name?]
271
- self.name = fix_name(metadata["name"])
272
- else
273
- self.name = metadata["name"]
274
- end
506
+ self.name = fix_name(self.name) if attributes[:python_fix_name?]
275
507
 
276
508
  # convert python-Foo to python-foo if flag is set
277
509
  self.name = self.name.downcase if attributes[:python_downcase_name?]
278
510
 
511
+ self.maintainer = metadata.maintainer
512
+
279
513
  if !attributes[:no_auto_depends?] and attributes[:python_dependencies?]
280
- metadata["dependencies"].each do |dep|
281
- dep_re = /^([^<>!= ]+)\s*(?:([~<>!=]{1,2})\s*(.*))?$/
514
+ # Python Dependency specifiers are a somewhat complex format described here:
515
+ # https://packaging.python.org/en/latest/specifications/dependency-specifiers/#environment-markers
516
+ #
517
+ # We can ask python's packaging module to parse and evaluate these.
518
+ # XXX: Allow users to override environnment values.
519
+ #
520
+ # Example:
521
+ # Requires-Dist: tzdata; sys_platform = win32
522
+ # Requires-Dist: asgiref>=3.8.1
523
+
524
+ dep_re = /^([^<>!= ]+)\s*(?:([~<>!=]{1,2})\s*(.*))?$/
525
+
526
+ reqs = []
527
+
528
+ # --python-obey-requirements-txt should use requirements.txt
529
+ # (if found in the python package) and replace the requirments listed from the metadata
530
+ if attributes[:python_obey_requirements_txt?] && !@requirements_txt.nil?
531
+ requires = @requirements_txt
532
+ else
533
+ requires = metadata.requires
534
+ end
535
+
536
+ # Evaluate python package requirements and only show ones matching the current environment
537
+ # (Environment markers, etc)
538
+ # Additionally, 'extra' features such as a requirement named `django[bcrypt]` isn't quite supported yet,
539
+ # since the marker.evaluate() needs to be passed some environment like { "extra": "bcrypt" }
540
+ execmd([attributes[:python_bin], File.expand_path(File.join("pyfpm", "parse_requires.py"), File.dirname(__FILE__))]) do |stdin, stdout, stderr|
541
+ requires.each { |r| stdin.puts(r) }
542
+ stdin.close
543
+ data = stdout.read
544
+ logger.pipe(stderr => :warn)
545
+ reqs += JSON.parse(data)
546
+ end
547
+
548
+ reqs.each do |dep|
282
549
  match = dep_re.match(dep)
283
550
  if match.nil?
284
551
  logger.error("Unable to parse dependency", :dependency => dep)
285
552
  raise FPM::InvalidPackageConfiguration, "Invalid dependency '#{dep}'"
286
553
  end
554
+
287
555
  name, cmp, version = match.captures
288
556
 
289
557
  next if attributes[:python_disable_dependency].include?(name)
@@ -302,8 +570,12 @@ class FPM::Package::Python < FPM::Package
302
570
  # convert dependencies from python-Foo to python-foo
303
571
  name = name.downcase if attributes[:python_downcase_dependencies?]
304
572
 
305
- self.dependencies << "#{name} #{cmp} #{version}"
306
- end
573
+ if cmp.nil? && version.nil?
574
+ self.dependencies << "#{name}"
575
+ else
576
+ self.dependencies << "#{name} #{cmp} #{version}"
577
+ end
578
+ end # parse Requires-Dist dependencies
307
579
  end # if attributes[:python_dependencies?]
308
580
  end # def load_package_info
309
581
 
@@ -323,55 +595,17 @@ class FPM::Package::Python < FPM::Package
323
595
  end # def fix_name
324
596
 
325
597
  # Install this package to the staging directory
326
- def install_to_staging(setup_py)
327
- project_dir = File.dirname(setup_py)
328
-
598
+ def install_to_staging(path)
329
599
  prefix = "/"
330
600
  prefix = attributes[:prefix] unless attributes[:prefix].nil?
331
601
 
332
- # Some setup.py's assume $PWD == current directory of setup.py, so let's
333
- # chdir first.
334
- ::Dir.chdir(project_dir) do
335
- flags = [ "--root", staging_path ]
336
- if !attributes[:python_install_lib].nil?
337
- flags += [ "--install-lib", File.join(prefix, attributes[:python_install_lib]) ]
338
- elsif !attributes[:prefix].nil?
339
- # setup.py install --prefix PREFIX still installs libs to
340
- # PREFIX/lib64/python2.7/site-packages/
341
- # but we really want something saner.
342
- #
343
- # since prefix is given, but not python_install_lib, assume PREFIX/lib
344
- flags += [ "--install-lib", File.join(prefix, "lib") ]
345
- end
346
-
347
- if !attributes[:python_install_data].nil?
348
- flags += [ "--install-data", File.join(prefix, attributes[:python_install_data]) ]
349
- elsif !attributes[:prefix].nil?
350
- # prefix given, but not python_install_data, assume PREFIX/data
351
- flags += [ "--install-data", File.join(prefix, "data") ]
352
- end
353
-
354
- if !attributes[:python_install_bin].nil?
355
- flags += [ "--install-scripts", File.join(prefix, attributes[:python_install_bin]) ]
356
- elsif !attributes[:prefix].nil?
357
- # prefix given, but not python_install_bin, assume PREFIX/bin
358
- flags += [ "--install-scripts", File.join(prefix, "bin") ]
359
- end
360
-
361
- if !attributes[:python_scripts_executable].nil?
362
- # Overwrite installed python scripts shebang binary with provided executable
363
- flags += [ "build_scripts", "--executable", attributes[:python_scripts_executable] ]
364
- end
602
+ # XXX: Note: pip doesn't seem to have any equivalent to `--install-lib` or similar flags.
603
+ # XXX: Deprecate :python_install_data, :python_install_lib, :python_install_bin
604
+ # XXX: Deprecate: :python_setup_py_arguments
605
+ flags = [ "--root", staging_path ]
606
+ flags += [ "--prefix", prefix ] if !attributes[:prefix].nil?
365
607
 
366
- if !attributes[:python_setup_py_arguments].nil? and !attributes[:python_setup_py_arguments].empty?
367
- # Add optional setup.py arguments
368
- attributes[:python_setup_py_arguments].each do |a|
369
- flags += [ a ]
370
- end
371
- end
372
-
373
- safesystem(attributes[:python_bin], "setup.py", "install", *flags)
374
- end
608
+ safesystem(*attributes[:python_pip], "install", "--no-deps", *flags, path)
375
609
  end # def install_to_staging
376
610
 
377
611
  public(:input)