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.
- checksums.yaml +4 -4
- data/CHANGELOG.rst +30 -0
- data/lib/fpm/command.rb +10 -2
- data/lib/fpm/package/deb.rb +102 -18
- data/lib/fpm/package/dir.rb +0 -4
- data/lib/fpm/package/freebsd.rb +23 -11
- data/lib/fpm/package/pacman.rb +8 -4
- data/lib/fpm/package/pyfpm/parse_requires.py +24 -0
- data/lib/fpm/package/python.rb +404 -170
- data/lib/fpm/package/rpm.rb +45 -5
- data/lib/fpm/package/virtualenv.rb +26 -2
- data/lib/fpm/package.rb +0 -1
- data/lib/fpm/rake_task.rb +30 -4
- data/lib/fpm/util.rb +33 -16
- data/lib/fpm/version.rb +1 -1
- data/templates/rpm.erb +1 -1
- metadata +10 -12
data/lib/fpm/package/python.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
102
|
-
|
103
|
-
|
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
|
107
|
-
|
108
|
-
|
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(
|
112
|
-
install_to_staging(
|
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.
|
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
|
-
"
|
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
|
-
|
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
|
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(
|
200
|
-
if
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
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
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
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
|
-
|
225
|
-
pylib = File.expand_path(File.dirname(__FILE__))
|
495
|
+
self.architecture = wheeldata["Root-Is-Purelib"] == "true" ? "all" : "native"
|
226
496
|
|
227
|
-
|
228
|
-
|
229
|
-
|
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
|
-
|
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
|
-
|
281
|
-
|
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
|
-
|
306
|
-
|
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(
|
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
|
-
#
|
333
|
-
#
|
334
|
-
|
335
|
-
|
336
|
-
|
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
|
-
|
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)
|