Compare commits

...

13 Commits

Author SHA1 Message Date
Jordan Sissel 3754c6b487 Add support for some uncommon package downloads.
* amqplib 1.0.2 downloads a file ending in .tgz instead of expected .tar.gz
* dnspython 1.15.0 has METADATA which includes a description body, but
  the metadata version is 2.0, and python core-metadata docs indicate
  v2.1 added support for description body. In this case, if the metadata
  version is 2.0, fpm now accepts a description body.

Reference: python core-metadata:
> Changed in version 2.1: This field may be specified in the message body instead.

Original report and fixes for this by @bugfood in #2002.
2025-09-30 15:15:03 -07:00
Jordan Sissel 697e6ffa74 Add line continuation to keep ruby-lsp from warning about useless void context. 2025-09-30 15:05:13 -07:00
Jordan Sissel 48dc536d94 Fix failing virtualenv tests
I hope this works? It works on my machine... :\

virtualenv and virtualenv-tools seem to be lesser-used these days. I
wonder if it'll be more useful to move to a different tool like venv...
2025-09-30 15:03:47 -07:00
Jordan Sissel ef5da20933 Update rspec in order to use satisfy(description, &block)
Older rspec didn't allow a description to be given to satisfy(), but
newer ones do. I think I forgot to include this rspec version update
in PR #2103
2025-09-30 14:05:43 -07:00
Jordan Sissel ee9ce01204 Fix typo 2025-09-30 13:54:30 -07:00
Jordan Sissel b78ff5a121 Use python to parse python requires syntax and evaluate markers.
Not supported yet:
* Custom environment for marker evaluation.
* "extras" markers

Restore function to `--python-obey-requirements-txt`. This flag will
only work on python inputs that are provided as a directory, not a
wheel/tar.gz/etc.

Tests pass including the obey requirements.txt ones.

* Fix bug 'else File.directory?` -> `elsif File.directory?...` lol oops.
* Use Dir.entries instead of Dir.glob to allow python package extra
  syntax, with names like `django[bcrypt]` (Note, extras in requirements
  aren't evaluated yet)
2025-09-30 13:54:30 -07:00
Jordan Sissel a555dc7d90 Fix typo 2025-09-30 13:54:30 -07:00
Jordan Sissel 84b47e0417 Work continues to center python package work around `pip` and away from setup.py
Working: Most python tests except for the --obey-requirements-text one
Breaking: Broke support for requirements.txt, for now.

* Deprecate --python-install-{bin,data,lib} flags since those are only
  supported by setup.py. `pip` just supports `--prefix` and maybe that's ok.
* Deprecate --python-scripts-executable for similar reasons. I don't
  think pip supports this?
* Add notes about unsupported Python dependency markers
* PythonMetadata is now a class with accessors providing name, version,
  requirements, homepage, maintainer, etc.
* Moved package url/homepage selection into PythonMetadata
* Fix bug where dependency list would have trailing spaces.
* Updated test suite with new python package name prefix selection
  (detecting python3, etc)
2025-09-30 13:54:30 -07:00
Jordan Sissel 1b8f547930 Add accessors for parts of python metadata that we can use with fpm.
(Note: Not all parts of the metadata are used, and some could be used
that are not yet used...)

* Add more tests for parsing and PythonMetadata members
* maintainer: Use Maintainer-email if present, otherwise use Author-email
* license: Try the best field for using as the license label
  (License-Expression, License, or a Classifier entry)
* homepage: Try to pick the right value for the homepage, or close
  enough, since python packages can have multiple urls as project urls.
2025-09-30 13:54:30 -07:00
Jordan Sissel d6e1dbae1a Improvements to python wheel processing
Parsing Python METADATA files:
* Treat any 'multiple use' fields (as documented in the python
  core-metadata spec) as arrays at all times.
* If METADATA has a body, use it as the description (per spec)
* If Description comes from a header, then strip leading space-pipe text
  as per spec.
* Allow the last header (field: value) to end without a newline.
  (Using \Z regex)
  This allows the parser to process both METADATA and WHEEL files.
* Only use Project-URL as package url if one is given in METADATA
* Set package maintainer based on Maintainer-Email field

Python Environment:
* Move default python detection to a separate method

Also:
* Fix typo in `pip wheel` invocation with wrong variable name
* Remove 'json' require since it's no longer used.
2025-09-30 13:54:30 -07:00
Jordan Sissel 959ccf27ed WIP to update python support to rely more on `pip`
Breaking:
* Removed calls to `setup.py` since this seems deprecated/unusable in
  almost any modern python package. [2]
* No longer work: `--python-install-data`, `--python-install-lib`, and
  `--python-install-bin`. The reason: `pip install ...` doesn't seem to
  support such flags, so there's no way to use these flags. Instead,
  you must set `--prefix` if you want to choose where the package
  installs if not the default location

New Behavior:
* When --python-bin isn't given to fpm, automatically try to find a
  correct python executable name[1]
* When --python-package-name-prefix isn't given, set a default based on
  the default --python-bin. That is, if python3 is being used, packages
  should default to a name prefix of "python3-"
* Package 'architecture' now will be "all" or "native" depending on the
  wheel file's `Root-Is-Purelib` field. It's unclear if this is the correct
  way to determine this, though.

Implementation Details:
* Parse package metadata from the wheel (METADATA and WHEEL files)
* Download pypi packages as wheels when available. Previously, fpm would
  specify `--no-binary :all:`, but no longer does.
* When source packages are only available, build a wheel locally.

Not yet tested:
* Not yet tested: Package a python module that exists in a local directory

[1] Python is sometimes available as python3, etc.
[2] https://packaging.python.org/en/latest/discussions/setup-py-deprecated/
2025-09-30 13:54:30 -07:00
Jordan Sissel 8a8ae741bf Move command running to verbose/info log instead of debug. 2025-09-30 13:54:30 -07:00
Jordan Sissel e6ff904fd3 Update cabin to fix Ruby ostruct deprecation/stdlib warning 2025-09-30 13:54:30 -07:00
8 changed files with 684 additions and 198 deletions

View File

@ -24,7 +24,7 @@ Gem::Specification.new do |spec|
# For logging
# https://github.com/jordansissel/ruby-cabin
spec.add_dependency("cabin", ">= 0.6.0") # license: Apache 2
spec.add_dependency("cabin", ">= 0.9.1") # license: Apache 2
# For backports to older rubies
# https://github.com/marcandre/backports
@ -46,7 +46,7 @@ Gem::Specification.new do |spec|
# so I think this needs to be added explicitly?
spec.add_dependency("rexml")
spec.add_development_dependency("rspec", "~> 3.0.0") # license: MIT (according to wikipedia)
spec.add_development_dependency("rspec", "~> 3.13.0") # license: MIT (according to wikipedia)
spec.add_development_dependency("insist", "~> 1.0.0") # license: Apache 2
spec.add_development_dependency("pry")

View File

@ -0,0 +1,24 @@
#!/usr/bin/env python3
#import pkg_resources
import packaging.requirements
import json
import sys
# Expect requirements lines via stdin.
#requirements = pkg_resources.parse_requirements(sys.stdin)
# Process environment markers, if any, and produce a list of requirements for the current environment.
def evaluate_requirements(fd):
all_requirements = [packaging.requirements.Requirement(line) for line in sys.stdin]
for req in all_requirements:
# XXX: Note: marker.evaluate() can be given a dict() containing environment values to overwrite
if req.marker is None or req.marker.evaluate():
if len(req.specifier) > 0:
for spec in req.specifier:
yield "%s%s" % (req.name, spec)
else:
yield str(req.name)
print(json.dumps(list(evaluate_requirements(sys.stdin))))

View File

@ -51,76 +51,386 @@ class FPM::Package::Python < FPM::Package
option "--downcase-dependencies", :flag, "Should the package dependencies " \
"be in lowercase?", :default => true
option "--install-bin", "BIN_PATH", "The path to where python scripts " \
"should be installed to."
option "--install-lib", "LIB_PATH", "The path to where python libs " \
option "--install-bin", "BIN_PATH", "(DEPRECATED, does nothing) The path to where python scripts " \
"should be installed to." do
logger.warn("Using deprecated flag --install-bin")
end
option "--install-lib", "LIB_PATH", "(DEPRECATED, does nothing) The path to where python libs " \
"should be installed to (default depends on your python installation). " \
"Want to find out what your target platform is using? Run this: " \
"python -c 'from distutils.sysconfig import get_python_lib; " \
"print get_python_lib()'"
option "--install-data", "DATA_PATH", "The path to where data should be " \
"print get_python_lib()'" do
logger.warn("Using deprecated flag --install-bin")
end
option "--install-data", "DATA_PATH", "(DEPRECATED, does nothing) The path to where data should be " \
"installed to. This is equivalent to 'python setup.py --install-data " \
"DATA_PATH"
option "--dependencies", :flag, "Include requirements defined in setup.py" \
"DATA_PATH" do
logger.warn("Using deprecated flag --install-bin")
end
option "--dependencies", :flag, "Include requirements defined by the python package" \
" as dependencies.", :default => true
option "--obey-requirements-txt", :flag, "Use a requirements.txt file " \
"in the top-level directory of the python package for dependency " \
"detection.", :default => false
option "--scripts-executable", "PYTHON_EXECUTABLE", "Set custom python " \
option "--scripts-executable", "PYTHON_EXECUTABLE", "(DEPRECATED) Set custom python " \
"interpreter in installing scripts. By default distutils will replace " \
"python interpreter in installing scripts (specified by shebang) with " \
"current python interpreter (sys.executable). This option is equivalent " \
"to appending 'build_scripts --executable PYTHON_EXECUTABLE' arguments " \
"to 'setup.py install' command."
"to 'setup.py install' command." do
logger.warn("Using deprecated flag --install-bin")
end
option "--disable-dependency", "python_package_name",
"The python package name to remove from dependency list",
:multivalued => true, :attribute_name => :python_disable_dependency,
:default => []
option "--setup-py-arguments", "setup_py_argument",
"Arbitrary argument(s) to be passed to setup.py",
"(DEPRECATED) Arbitrary argument(s) to be passed to setup.py",
:multivalued => true, :attribute_name => :python_setup_py_arguments,
:default => []
:default => [] do
logger.warn("Using deprecated flag --install-bin")
end
option "--internal-pip", :flag,
"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",
:attribute_name => :python_internal_pip,
:default => true
# Environment markers which are known but not yet supported by fpm.
# For some of these markers, it's not even clear if they are useful to fpm's packaging step.
# https://packaging.python.org/en/latest/specifications/dependency-specifiers/#dependency-specifiers
#
# XXX: python's setuptools.pkg_resources can help parse and evaluate such things, even if that library is deprecated:
# >>> f = open("spec/fixtures/python/requirements.txt"); a = pkg_resources.parse_requirements(f.read()); f.close();
# >>> [x for x in list(a) if x.marker is None or x.marker.evaluate()]
# [Requirement.parse('rtxt-dep1>0.1'), Requirement.parse('rtxt-dep2==0.1'), Requirement.parse('rtxt-dep4; python_version > "2.0"')]
#
# Another example, showing only requirements which have environment markers which evaluate to true (or have no markers)
# python3 -c 'import pkg_resources; import json;import sys; r = pkg_resources.parse_requirements(sys.stdin); deps = [d for d in list(r) if d.marker is None or d.marker.evaluate()]; pr
# ["rtxt-dep1>0.1", "rtxt-dep2==0.1", "rtxt-dep4; python_version > \"2.0\""]
UNSUPPORTED_DEPENDENCY_MARKERS = %w(python_version python_full_version os_name platform_release platform_system platform_version
platform_machine platform_python_implementation implementation_name implementation_version)
private
class PythonMetadata
require "strscan"
class MissingField < StandardError; end
class UnexpectedContent < StandardError; end
# According to https://packaging.python.org/en/latest/specifications/core-metadata/
# > Core Metadata v2.4 - August 2024
MULTIPLE_USE = %w(Dynamic Platform Supported-Platform License-File Classifier Requires-Dist Requires-External Project-URL Provides-Extra Provides-Dist Obsoletes-Dist)
# METADATA files are described in Python Packaging "Core Metadata"[1] and appear to have roughly RFC822 syntax.
# [1] https://packaging.python.org/en/latest/specifications/core-metadata/#core-metadata
def self.parse(input)
s = StringScanner.new(input)
headers = {}
# Default "Multiple use" fields to empty array instead of nil.
MULTIPLE_USE.each do |field|
headers[field] = []
end
while !s.eos? and !s.scan("\n") do
# Field is non-space up, but excluding the colon
field = s.scan(/[^\s:]+/)
# Skip colon and following whitespace
s.scan(/:\s*/)
# Value is text until newline, and any following lines if they have leading spaces.
value = s.scan(/[^\n]+(?:\Z|\n(?:[ \t][^\n]+\n)*)/)
if value.nil?
raise "Failed parsing Python package metadata value at field #{field}, char offset #{s.pos}"
end
value = value.chomp
if MULTIPLE_USE.include?(field)
raise "Header field should be an array. This is a bug in fpm." if !headers[field].is_a?(Array)
headers[field] << value
else
headers[field] = value
end
end # while reading headers
# If there's more content beyond the last header, then it's a content body.
# In Python Metadata >= 2.1, the descriptino can be written in the body.
if !s.eos?
if headers["Metadata-Version"].to_f >= 2.1
# Per Python core-metadata spec:
# > Changed in version 2.1: This field may be specified in the message body instead.
#return PythonMetadata.new(headers, s.string[s.pos ...])
return headers, s.string[s.pos ... ]
elsif headers["Metadata-Version"].to_f >= 2.0
# dnspython v1.15.0 has a description body and Metadata-Version 2.0
# this seems out of spec, but let's accept it anyway.
return headers, s.string[s.pos ... ]
else
raise "After reading METADATA headers, extra data is in the file but was not expected. This may be a bug in fpm."
end
end
#return PythonMetadata.new(headers)
return headers, nil # nil means no body in this metadata
rescue => e
puts "String scan failed: #{e}"
puts "Position: #{s.pointer}"
puts "---"
puts input
puts "==="
puts input[s.pointer...]
puts "---"
raise e
end # self.parse
def self.from(input)
return PythonMetadata.new(*parse(input))
end
# Only focusing on terms fpm may care about
attr_reader :name, :version, :summary, :description, :keywords, :maintainer, :license, :requires, :homepage
FIELD_MAP = {
:@name => "Name",
:@version => "Version",
:@summary => "Summary",
:@description => "Description",
:@keywords => "Keywords",
:@maintainer => "Author-email",
# Note: License can also come from the deprecated "License" field
# This is processed later in this method.
:@license => "License-Expression",
:@requires => "Requires-Dist",
}
REQUIRED_FIELDS = [ "Metadata-Version", "Name", "Version" ]
# headers - a Hash containing field-value pairs from headers as read from a python METADATA file.
# body - optional, a string containing the body text of a METADATA file
def initialize(headers, body=nil)
REQUIRED_FIELDS.each do |field|
if !headers.include?(field)
raise MissingField, "Missing required Python metadata field, '#{field}'. This might be a bug in the package or in fpm."
end
end
FIELD_MAP.each do |attr, field|
if headers.include?(field)
instance_variable_set(attr, headers.fetch(field))
end
end
# Do any extra processing on fields to turn them into their expected content.
process_description(headers, body)
process_license(headers)
process_homepage(headers)
process_maintainer(headers)
end # def initialize
private
def process_description(headers, body)
if @description
# Per python core-metadata spec:
# > To support empty lines and lines with indentation with respect to the
# > RFC 822 format, any CRLF character has to be suffixed by 7 spaces
# > followed by a pipe (“|”) char. As a result, the Description field is
# > encoded into a folded field that can be interpreted by RFC822 parser [2].
@description = @description.gsub!(/^ |/, "")
end
if !body.nil?
if headers["Metadata-Version"].to_f >= 2.1
# Per Python core-metadata spec:
# > Changed in version 2.1: [Description] field may be specified in the message body instead.
#
# The description is simply the rest of the METADATA file after the headers.
@description = body
elsif headers["Metadata-Version"].to_f >= 2.0
# dnspython v1.15.0 has a description body and Metadata-Version 2.0
# this seems out of spec, but let's accept it anyway.
@description = body
else
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."
end
# What to do if we find a description body but already have a Description field set in the headers?
if headers.include?("Description")
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."
end
end
# XXX: The description field can be markdown, plain text, or reST.
# Content type is noted in the "Description-Content-Type" field
# Should we transform this to plain text?
end # process_description
def process_license(headers)
# Ignore the "License" field if License-Expression is also present.
return if headers["Metadata-Version"].to_f >= 2.4 && headers.include?("License-Expression")
# Deprecated field, License, as described in python core-metadata:
# > As of Metadata 2.4, License and License-Expression are mutually exclusive.
# > If both are specified, tools which parse metadata will disregard License
# > and PyPI will reject uploads. See PEP 639.
if headers["License"]
# Note: This license can be free form text, so it's unclear if it's a great choice.
# however, the original python metadata License field is quite old/deprecated
# so maybe nobody uses it anymore?
@license = headers["License"]
elsif license_classifier = headers["Classifier"].find { |value| value =~ /^License ::/ }
# The license could also show up in the "Classifier" header with "License ::" as a prefix.
@license = license_classifier.sub(/^License ::/, "")
end # check for deprecated License field
end # process_license
def process_homepage(headers)
return if headers["Project-URL"].empty?
# Create a hash of Project-URL where the label is the key, url the value.
urls = Hash[*headers["Project-URL"].map do |text|
label, url = text.split(/, */, 2)
# Normalize the label by removing punctuation and spaces
# Reference: https://packaging.python.org/en/latest/specifications/well-known-project-urls/#label-normalization
# > In plain language: a label is normalized by deleting all ASCII punctuation and whitespace, and then converting the result to lowercase.
label = label.gsub(/[[:punct:][:space:]]/, "").downcase
[label, url]
end.flatten(1)]
# Prioritize certain URL labels when choosing the homepage url.
[ "homepage", "source", "documentation", "releasenotes" ].each do |label|
if urls.include?(label)
@homepage = urls[label]
end
end
# Otherwise, default to the first URL
@homepage = urls.values.first
end
def process_maintainer(headers)
# Python metadata supports both "Author-email" and "Maintainer-email"
# Of the "Maintainer" fields, python core-metadata says:
# > Note that this field is intended for use when a project is being maintained by someone other than the original author
#
# So we should prefer Maintainer-email if it exists, but fall back to Author-email otherwise.
@maintainer = headers["Maintainer-email"] unless headers["Maintainer-email"].nil?
end
end # class PythonMetadata
# Input a package.
#
# The 'package' can be any of:
#
# * A name of a package on pypi (ie; easy_install some-package)
# * The path to a directory containing setup.py
# * The path to a setup.py
# * The path to a directory containing setup.py or pypackage.toml
# * The path to a setup.py or pypackage.toml
# * The path to a python sdist file ending in .tar.gz
# * The path to a python wheel file ending in .whl
def input(package)
#if attributes[:python_obey_requirements_txt?]
#raise "--python-obey-requirements-txt is temporarily unsupported at this time."
#end
explore_environment
path_to_package = download_if_necessary(package, version)
# Expect a setup.py or pypackage.toml if it's a directory.
if File.directory?(path_to_package)
setup_py = File.join(path_to_package, "setup.py")
else
setup_py = path_to_package
if !(File.exist?(File.join(path_to_package, "setup.py")) or File.exist?(File.join(path_to_package, "pypackage.toml")))
logger.error("The path doesn't appear to be a python package directory. I expected either a pypackage.toml or setup.py but found neither.", :package => package)
raise "Unable to find python package; tried #{setup_py}"
end
if attributes[:python_obey_requirements_txt?] && File.exist?(File.join(path_to_package, "requirements.txt"))
@requirements_txt = File.read(File.join(path_to_package, "requirements.txt"))
end
end
if !File.exist?(setup_py)
logger.error("Could not find 'setup.py'", :path => setup_py)
raise "Unable to find python package; tried #{setup_py}"
if File.file?(path_to_package)
if ["setup.py", "pypackage.toml"].include?(File.basename(path_to_package))
path_to_package = File.dirname(path_to_package)
end
end
load_package_info(setup_py)
install_to_staging(setup_py)
if [".tar.gz", ".tgz"].any? { |suffix| path_to_package.end_with?(suffix) }
# Have pip convert the .tar.gz (source dist?) into a wheel
logger.debug("Found tarball and assuming it's a python source package.")
safesystem(*attributes[:python_pip], "wheel", "--no-deps", "-w", build_path, path_to_package)
path_to_package = ::Dir.glob(build_path("*.whl")).first
if path_to_package.nil?
log.error("Failed building python package wheel format. This might be a bug in fpm.")
raise "Failed building python package format."
end
elsif File.directory?(path_to_package)
logger.debug("Found directory and assuming it's a python source package.")
safesystem(*attributes[:python_pip], "wheel", "--no-deps", "-w", build_path, path_to_package)
path_to_package = ::Dir.glob(build_path("*.whl")).first
if path_to_package.nil?
log.error("Failed building python package wheel format. This might be a bug in fpm.")
raise "Failed building python package format."
end
end
load_package_info(path_to_package)
install_to_staging(path_to_package)
end # def input
def explore_environment
if !attributes[:python_bin_given?]
# If --python-bin isn't set, try to find a good default python executable path, because it might not be "python"
pythons = [ "python", "python3", "python2" ]
default_python = pythons.find { |py| program_exists?(py) }
if default_python.nil?
raise FPM::Util::ExecutableNotFound, "Could not find any python interpreter. Tried the following: #{pythons.join(", ")}"
end
logger.info("Setting default python executable", :name => default_python)
attributes[:python_bin] = default_python
if !attributes[:python_package_name_prefix_given?]
attributes[:python_package_name_prefix] = default_python
logger.info("Setting package name prefix", :name => default_python)
end
end
if attributes[:python_internal_pip?]
# XXX: Should we detect if internal pip is available?
attributes[:python_pip] = [ attributes[:python_bin], "-m", "pip"]
end
end # explore_environment
# Download the given package if necessary. If version is given, that version
# will be downloaded, otherwise the latest is fetched.
def download_if_necessary(package, version=nil)
# TODO(sissel): this should just be a 'download' method, the 'if_necessary'
# part should go elsewhere.
path = package
# If it's a path, assume local build.
if File.directory?(path) or (File.exist?(path) and File.basename(path) == "setup.py")
return path
if File.exist?(path)
return path if File.directory?(path)
return path if path.end_with?(".tar.gz")
return path if path.end_with?(".tgz") # amqplib v1.0.2 does this
return path if path.end_with?(".whl")
return path if path.end_with?(".zip")
return path if File.exist?(File.join(path, "setup.py"))
return path if File.exist?(File.join(path, "pyproject.toml"))
raise [
"Local file doesn't appear to be a supported type for a python package. Expected one of:",
" - A directory containing setup.py or pyproject.toml",
" - A file ending in .tar.gz (a python source dist)",
" - A file ending in .whl (a python wheel)",
].join("\n")
end
logger.info("Trying to download", :package => package)
@ -134,24 +444,16 @@ class FPM::Package::Python < FPM::Package
target = build_path(package)
FileUtils.mkdir(target) unless File.directory?(target)
if attributes[:python_internal_pip?]
# XXX: Should we detect if internal pip is available?
attributes[:python_pip] = [ attributes[:python_bin], "-m", "pip"]
end
# attributes[:python_pip] -- expected to be a path
if attributes[:python_pip]
logger.debug("using pip", :pip => attributes[:python_pip])
# TODO: Support older versions of pip
pip = [attributes[:python_pip]] if pip.is_a?(String)
setup_cmd = [
*attributes[:python_pip],
"download",
"--no-clean",
"--no-deps",
"--no-binary", ":all:",
"-d", build_path,
"-d", target,
"-i", attributes[:python_pypi],
]
@ -166,130 +468,104 @@ class FPM::Package::Python < FPM::Package
safesystem(*setup_cmd)
# Pip removed the --build flag sometime in 2021, it seems: https://github.com/pypa/pip/issues/8333
# A workaround for pip removing the `--build` flag. Previously, `pip download --build ...` would leave
# behind a directory with the Python package extracted and ready to be used.
# For example, `pip download ... Django` puts `Django-4.0.4.tar.tz` into the build_path directory.
# If we expect `pip` to leave an unknown-named file in the `build_path` directory, let's check for
# a single file and unpack it.
files = ::Dir.glob(File.join(build_path, "*.{tar.gz,zip}"))
files = ::Dir.entries(target).filter { |entry| entry =~ /\.(whl|tgz|tar\.gz|zip)$/ }
if files.length != 1
raise "Unexpected directory layout after `pip download ...`. This might be an fpm bug? The directory is #{build_path}"
end
if files[0].end_with?("tar.gz")
safesystem("tar", "-zxf", files[0], "-C", target)
elsif files[0].end_with?("zip")
safesystem("unzip", files[0], "-d", target)
else
raise "Unexpected file format after `pip download ...`. This might be an fpm bug? The file is #{files[0]}"
raise "Unexpected directory layout after `pip download ...`. This might be an fpm bug? The directory contains these files: #{files.inspect}"
end
return File.join(target, files.first)
else
# no pip, use easy_install
logger.debug("no pip, defaulting to easy_install", :easy_install => attributes[:python_easyinstall])
safesystem(attributes[:python_easyinstall], "-i",
attributes[:python_pypi], "--editable", "-U",
"--build-directory", target, want_pkg)
# easy_install will put stuff in @tmpdir/packagename/, so find that:
# @tmpdir/somepackage/setup.py
#dirs = ::Dir.glob(File.join(target, "*"))
files = ::Dir.entries(target).filter { |entry| entry != "." && entry != ".." }
if dirs.length != 1
raise "Unexpected directory layout after easy_install. Maybe file a bug? The directory is #{build_path}"
end
return dirs.first
end
# easy_install will put stuff in @tmpdir/packagename/, so find that:
# @tmpdir/somepackage/setup.py
dirs = ::Dir.glob(File.join(target, "*"))
if dirs.length != 1
raise "Unexpected directory layout after easy_install. Maybe file a bug? The directory is #{build_path}"
end
return dirs.first
end # def download
# Load the package information like name, version, dependencies.
def load_package_info(setup_py)
if !attributes[:python_package_prefix].nil?
attributes[:python_package_name_prefix] = attributes[:python_package_prefix]
end
begin
json_test_code = [
"try:",
" import json",
"except ImportError:",
" import simplejson as json"
].join("\n")
safesystem("#{attributes[:python_bin]} -c '#{json_test_code}'")
rescue FPM::Util::ProcessFailed => e
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)
raise FPM::Util::ProcessFailed, "Python (#{attributes[:python_bin]}) is missing simplejson or json modules."
end
begin
safesystem("#{attributes[:python_bin]} -c 'import pkg_resources'")
rescue FPM::Util::ProcessFailed => e
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)
raise FPM::Util::ProcessFailed, "Python (#{attributes[:python_bin]}) is missing pkg_resources module."
end
# Add ./pyfpm/ to the python library path
pylib = File.expand_path(File.dirname(__FILE__))
# chdir to the directory holding setup.py because some python setup.py's assume that you are
# in the same directory.
setup_dir = File.dirname(setup_py)
output = ::Dir.chdir(setup_dir) do
tmp = build_path("metadata.json")
setup_cmd = "env PYTHONPATH=#{pylib.shellescape}:$PYTHONPATH #{attributes[:python_bin]} " \
"setup.py --command-packages=pyfpm get_metadata --output=#{tmp}"
if attributes[:python_obey_requirements_txt?]
setup_cmd += " --load-requirements-txt"
def load_package_info(path)
if path.end_with?(".whl")
# XXX: Maybe use rubyzip to parse the .whl (zip) file instead?
metadata = nil
execmd(["unzip", "-p", path, "*.dist-info/METADATA"], :stdin => false, :stderr => false) do |stdout|
metadata = PythonMetadata.from(stdout.read(64<<10))
end
# Capture the output, which will be JSON metadata describing this python
# package. See fpm/lib/fpm/package/pyfpm/get_metadata.py for more
# details.
logger.info("fetching package metadata", :setup_cmd => setup_cmd)
success = safesystem(setup_cmd)
#%x{#{setup_cmd}}
if !success
logger.error("setup.py get_metadata failed", :command => setup_cmd,
:exitcode => $?.exitstatus)
raise "An unexpected error occurred while processing the setup.py file"
wheeldata = nil
execmd(["unzip", "-p", path, "*.dist-info/WHEEL"], :stdin => false, :stderr => false) do |stdout|
wheeldata, _ = PythonMetadata.parse(stdout.read(64<<10))
end
File.read(tmp)
else
raise "Unexpected python package path. This might be an fpm bug? The path is #{path}"
end
logger.debug("result from `setup.py get_metadata`", :data => output)
metadata = JSON.parse(output)
logger.info("object output of get_metadata", :json => metadata)
self.architecture = metadata["architecture"]
self.description = metadata["description"]
# Sometimes the license field is multiple lines; do best-effort and just
# use the first line.
if metadata["license"]
self.license = metadata["license"].split(/[\r\n]+/).first
end
self.version = metadata["version"]
self.url = metadata["url"]
self.architecture = wheeldata["Root-Is-Purelib"] == "true" ? "all" : "native"
self.description = metadata.description unless metadata.description.nil?
self.license = metadata.license unless metadata.license.nil?
self.version = metadata.version
self.url = metadata.homepage unless metadata.homepage.nil?
self.name = metadata.name
# name prefixing is optional, if enabled, a name 'foo' will become
# 'python-foo' (depending on what the python_package_name_prefix is)
if attributes[:python_fix_name?]
self.name = fix_name(metadata["name"])
else
self.name = metadata["name"]
end
self.name = fix_name(self.name) if attributes[:python_fix_name?]
# convert python-Foo to python-foo if flag is set
self.name = self.name.downcase if attributes[:python_downcase_name?]
self.maintainer = metadata.maintainer
if !attributes[:no_auto_depends?] and attributes[:python_dependencies?]
metadata["dependencies"].each do |dep|
dep_re = /^([^<>!= ]+)\s*(?:([~<>!=]{1,2})\s*(.*))?$/
# Python Dependency specifiers are a somewhat complex format described here:
# https://packaging.python.org/en/latest/specifications/dependency-specifiers/#environment-markers
#
# We can ask python's packaging module to parse and evaluate these.
# XXX: Allow users to override environnment values.
#
# Example:
# Requires-Dist: tzdata; sys_platform = win32
# Requires-Dist: asgiref>=3.8.1
dep_re = /^([^<>!= ]+)\s*(?:([~<>!=]{1,2})\s*(.*))?$/
reqs = []
# --python-obey-requirements-txt should replace the requirments listed from the metadata
if attributes[:python_obey_requirements_txt?] && !@requirements_txt.nil?
requires = @requirements_txt.split("\n")
else
requires = metadata.requires
end
# Evaluate python package requirements and only show ones matching the current environment
# (Environment markers, etc)
# Additionally, 'extra' features such as a requirement named `django[bcrypt]` isn't quite supported yet,
# since the marker.evaluate() needs to be passed some environment like { "extra": "bcrypt" }
execmd([attributes[:python_bin], File.expand_path(File.join("pyfpm", "parse_requires.py"), File.dirname(__FILE__))]) do |stdin, stdout, stderr|
requires.each { |r| stdin.puts(r) }
stdin.close
data = stdout.read
logger.pipe(stderr => :warn)
reqs += JSON.parse(data)
end
reqs.each do |dep|
match = dep_re.match(dep)
if match.nil?
logger.error("Unable to parse dependency", :dependency => dep)
raise FPM::InvalidPackageConfiguration, "Invalid dependency '#{dep}'"
end
name, cmp, version = match.captures
next if attributes[:python_disable_dependency].include?(name)
@ -308,8 +584,12 @@ class FPM::Package::Python < FPM::Package
# convert dependencies from python-Foo to python-foo
name = name.downcase if attributes[:python_downcase_dependencies?]
self.dependencies << "#{name} #{cmp} #{version}"
end
if cmp.nil? && version.nil?
self.dependencies << "#{name}"
else
self.dependencies << "#{name} #{cmp} #{version}"
end
end # parse Requires-Dist dependencies
end # if attributes[:python_dependencies?]
end # def load_package_info
@ -329,55 +609,17 @@ class FPM::Package::Python < FPM::Package
end # def fix_name
# Install this package to the staging directory
def install_to_staging(setup_py)
project_dir = File.dirname(setup_py)
def install_to_staging(path)
prefix = "/"
prefix = attributes[:prefix] unless attributes[:prefix].nil?
# Some setup.py's assume $PWD == current directory of setup.py, so let's
# chdir first.
::Dir.chdir(project_dir) do
flags = [ "--root", staging_path ]
if !attributes[:python_install_lib].nil?
flags += [ "--install-lib", File.join(prefix, attributes[:python_install_lib]) ]
elsif !attributes[:prefix].nil?
# setup.py install --prefix PREFIX still installs libs to
# PREFIX/lib64/python2.7/site-packages/
# but we really want something saner.
#
# since prefix is given, but not python_install_lib, assume PREFIX/lib
flags += [ "--install-lib", File.join(prefix, "lib") ]
end
# XXX: Note: pip doesn't seem to have any equivalent to `--install-lib` or similar flags.
# XXX: Deprecate :python_install_data, :python_install_lib, :python_install_bin
# XXX: Deprecate: :python_setup_py_arguments
flags = [ "--root", staging_path ]
flags += [ "--prefix", prefix ] if !attributes[:prefix].nil?
if !attributes[:python_install_data].nil?
flags += [ "--install-data", File.join(prefix, attributes[:python_install_data]) ]
elsif !attributes[:prefix].nil?
# prefix given, but not python_install_data, assume PREFIX/data
flags += [ "--install-data", File.join(prefix, "data") ]
end
if !attributes[:python_install_bin].nil?
flags += [ "--install-scripts", File.join(prefix, attributes[:python_install_bin]) ]
elsif !attributes[:prefix].nil?
# prefix given, but not python_install_bin, assume PREFIX/bin
flags += [ "--install-scripts", File.join(prefix, "bin") ]
end
if !attributes[:python_scripts_executable].nil?
# Overwrite installed python scripts shebang binary with provided executable
flags += [ "build_scripts", "--executable", attributes[:python_scripts_executable] ]
end
if !attributes[:python_setup_py_arguments].nil? and !attributes[:python_setup_py_arguments].empty?
# Add optional setup.py arguments
attributes[:python_setup_py_arguments].each do |a|
flags += [ a ]
end
end
safesystem(attributes[:python_bin], "setup.py", "install", *flags)
end
safesystem(*attributes[:python_pip], "install", "--no-deps", *flags, path)
end # def install_to_staging
public(:input)

View File

@ -36,7 +36,7 @@ class FPM::Package::Virtualenv < FPM::Package
:default => nil
option "--setup-install", :flag, "After building virtualenv run setup.py install "\
"useful when building a virtualenv for packages and including their requirements from "
"useful when building a virtualenv for packages and including their requirements from "\
"requirements.txt"
option "--system-site-packages", :flag, "Give the virtual environment access to the "\
@ -158,6 +158,31 @@ class FPM::Package::Virtualenv < FPM::Package
end
end
# [2025-09-30] virtualenv-tools seems broken?
# The --update-path will look for a VIRTUAL_ENV= line in bin/activate,
# however, the version I tested looks for it with quotations, like VIRTUAL_ENV='
# And at time of writing, my `virtualenv` tool doesn't use quotations on this variable
#
# Maybe best case we can patch it here instead. The path update tool
# looks for the original virtualenv path and I think updates any bin
# files which point to it.
patched = []
activate_bin = File.join(virtualenv_build_folder, "bin/activate")
fd = File.open(activate_bin)
fd.each_line do |line|
re = /^VIRTUAL_ENV=([^'"].*)$/
match = line.match(re)
if match
# Quote the VIRTUAL_ENV var assignment to help virtualenv-tools work?
patched << "VIRTUAL_ENV='#{match}'\n"
else
patched << line
end
end
fd.close
File.write(activate_bin, patched.join)
# Rewrite the base path inside the virtualenv to prepare it to be packaged.
::Dir.chdir(virtualenv_build_folder) do
safesystem("virtualenv-tools", "--update-path", virtualenv_folder)
end
@ -191,7 +216,6 @@ class FPM::Package::Virtualenv < FPM::Package
dir.input(".")
@staging_path = dir.staging_path
dir.cleanup_build
end # def input
# Delete python precompiled files found in a given folder.

View File

@ -136,7 +136,7 @@ module FPM::Util
raise ExecutableNotFound.new(program)
end
logger.debug("Running command", :args => args2)
logger.info("Running command", :args => args2)
stdout_r, stdout_w = IO.pipe
stderr_r, stderr_w = IO.pipe

99
spec/fixtures/python/METADATA vendored Normal file
View File

@ -0,0 +1,99 @@
Metadata-Version: 2.4
Name: Django
Version: 5.2.6
Summary: A high-level Python web framework that encourages rapid development and clean, pragmatic design.
Author-email: Django Software Foundation <foundation@djangoproject.com>
License: BSD-3-Clause
Project-URL: Homepage, https://www.djangoproject.com/
Project-URL: Documentation, https://docs.djangoproject.com/
Project-URL: Release notes, https://docs.djangoproject.com/en/stable/releases/
Project-URL: Funding, https://www.djangoproject.com/fundraising/
Project-URL: Source, https://github.com/django/django
Project-URL: Tracker, https://code.djangoproject.com/
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: BSD License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
Classifier: Topic :: Internet :: WWW/HTTP :: WSGI
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/x-rst
License-File: LICENSE
License-File: LICENSE.python
License-File: AUTHORS
Requires-Dist: asgiref>=3.8.1
Requires-Dist: sqlparse>=0.3.1
Requires-Dist: tzdata; sys_platform == "win32"
Provides-Extra: argon2
Requires-Dist: argon2-cffi>=19.1.0; extra == "argon2"
Provides-Extra: bcrypt
Requires-Dist: bcrypt; extra == "bcrypt"
Dynamic: license-file
======
Django
======
Django is a high-level Python web framework that encourages rapid development
and clean, pragmatic design. Thanks for checking it out.
All documentation is in the "``docs``" directory and online at
https://docs.djangoproject.com/en/stable/. If you're just getting started,
here's how we recommend you read the docs:
* First, read ``docs/intro/install.txt`` for instructions on installing Django.
* Next, work through the tutorials in order (``docs/intro/tutorial01.txt``,
``docs/intro/tutorial02.txt``, etc.).
* If you want to set up an actual deployment server, read
``docs/howto/deployment/index.txt`` for instructions.
* You'll probably want to read through the topical guides (in ``docs/topics``)
next; from there you can jump to the HOWTOs (in ``docs/howto``) for specific
problems, and check out the reference (``docs/ref``) for gory details.
* See ``docs/README`` for instructions on building an HTML version of the docs.
Docs are updated rigorously. If you find any problems in the docs, or think
they should be clarified in any way, please take 30 seconds to fill out a
ticket here: https://code.djangoproject.com/newticket
To get more help:
* Join the ``#django`` channel on ``irc.libera.chat``. Lots of helpful people
hang out there. `Webchat is available <https://web.libera.chat/#django>`_.
* Join the `Django Discord community <https://chat.djangoproject.com>`_.
* Join the community on the `Django Forum <https://forum.djangoproject.com/>`_.
To contribute to Django:
* Check out https://docs.djangoproject.com/en/dev/internals/contributing/ for
information about getting involved.
To run Django's test suite:
* Follow the instructions in the "Unit tests" section of
``docs/internals/contributing/writing-code/unit-tests.txt``, published online at
https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/unit-tests/#running-the-unit-tests
Supporting the Development of Django
====================================
Django's development depends on your contributions.
If you depend on Django, remember to support the Django Software Foundation: https://www.djangoproject.com/fundraising/

View File

@ -10,9 +10,6 @@ setup(name="Example",
package_dir={},
install_requires=[
"Dependency1", "dependency2",
# XXX: I don't know what these python_version-dependent deps mean
# needs investigation
# Reference: PEP-0508
'rtxt-dep3; python_version == "2.0"',
'rtxt-dep4; python_version > "2.0"',
],

View File

@ -35,7 +35,7 @@ end
describe FPM::Package::Python do
before do
skip("Python program not found") unless python_usable?
subject.attributes[:python_bin] = find_python
#subject.attributes[:python_bin] = find_python
end
let (:example_dir) do
@ -50,16 +50,15 @@ describe FPM::Package::Python do
before :each do
subject.attributes[:python_downcase_name?] = false
end
context "when :python_fix_name? is true" do
before :each do
subject.attributes[:python_fix_name?] = true
end
context "and :python_package_name_prefix is nil/default" do
it "should prefix the package with 'python-'" do
it "should prefix the package name based on detected python-bin name" do
subject.input(example_dir)
insist { subject.name } == "python-Example"
insist { subject.name } == "#{subject.attributes[:python_bin]}-Example"
end
end
@ -67,6 +66,7 @@ describe FPM::Package::Python do
it "should prefix the package name appropriately" do
prefix = "whoa"
subject.attributes[:python_package_name_prefix] = prefix
subject.attributes[:python_package_name_prefix_given?] = true
subject.input(example_dir)
insist { subject.name } == "#{prefix}-Example"
end
@ -96,9 +96,10 @@ describe FPM::Package::Python do
end
context "and :python_package_name_prefix is nil/default" do
it "should prefix the package with 'python-'" do
it "should prefix the package based on the version of python" do
subject.input(example_dir)
insist { subject.name } == "python-example"
insist { subject.attributes[:python_package_name_prefix_given?] }.nil?
insist { subject.name } == "#{subject.attributes[:python_bin]}-example"
end
end
@ -106,6 +107,7 @@ describe FPM::Package::Python do
it "should prefix the package name appropriately" do
prefix = "whoa"
subject.attributes[:python_package_name_prefix] = prefix
subject.attributes[:python_package_name_prefix_given?] = true
subject.input(example_dir)
insist { subject.name } == "#{prefix}-example"
end
@ -130,9 +132,24 @@ describe FPM::Package::Python do
end
it "it should include the dependencies from setup.py" do
# Insist on using the defaults for this test, prefix not given and
# prefix should automatically be based on the python major version
insist { subject.attributes[:python_package_name_prefix_given?] }.nil?
subject.input(example_dir)
prefix = subject.attributes[:python_package_name_prefix]
# The package name prefix attribute should be set to _something_ by default
reject { prefix }.nil?
# XXX: Why is there extra whitespace in these strings?
insist { subject.dependencies.sort } == ["python-dependency1 ","python-dependency2 ", "python-rtxt-dep4 "]
#
# Note: The dependency list should only include entries which are supported by fpm.
# python dependencies can have 'environment markers' and most of those markers are
# not supported by fpm.
# In this test, there are (at time of writing) some python_version markers and fpm doesn't
# support those.
insist { subject.dependencies.sort } == ["#{prefix}-dependency1","#{prefix}-dependency2", "#{prefix}-rtxt-dep4"]
end
context "and :python_disable_dependency is set" do
@ -142,7 +159,8 @@ describe FPM::Package::Python do
it "it should exclude the dependency" do
subject.input(example_dir)
insist { subject.dependencies.sort } == ["python-dependency2 ", "python-rtxt-dep4 "]
prefix = subject.attributes[:python_package_name_prefix]
insist { subject.dependencies.sort } == ["#{prefix}-dependency2", "#{prefix}-rtxt-dep4"]
end
end
end
@ -160,13 +178,15 @@ describe FPM::Package::Python do
it "it should prefix requirements.txt" do
subject.input(example_dir)
insist { subject.dependencies.sort } == ["python-rtxt-dep1 > 0.1", "python-rtxt-dep2 = 0.1", "python-rtxt-dep4 "]
prefix = subject.attributes[:python_package_name_prefix]
insist { subject.dependencies.sort } == ["#{prefix}-rtxt-dep1 > 0.1", "#{prefix}-rtxt-dep2 = 0.1", "#{prefix}-rtxt-dep4"]
end
it "it should exclude the dependency" do
subject.attributes[:python_disable_dependency] = "rtxt-dep1"
subject.input(example_dir)
insist { subject.dependencies.sort } == ["python-rtxt-dep2 = 0.1", "python-rtxt-dep4 "]
prefix = subject.attributes[:python_package_name_prefix]
insist { subject.dependencies.sort } == ["#{prefix}-rtxt-dep2 = 0.1", "#{prefix}-rtxt-dep4"]
end
end
@ -177,21 +197,20 @@ describe FPM::Package::Python do
it "it should load requirements.txt" do
subject.input(example_dir)
insist { subject.dependencies.sort } == ["rtxt-dep1 > 0.1", "rtxt-dep2 = 0.1", "rtxt-dep4 "]
insist { subject.dependencies.sort } == ["rtxt-dep1 > 0.1", "rtxt-dep2 = 0.1", "rtxt-dep4"]
end
it "it should exclude the dependency" do
subject.attributes[:python_disable_dependency] = "rtxt-dep1"
subject.input(example_dir)
insist { subject.dependencies.sort } == ["rtxt-dep2 = 0.1", "rtxt-dep4 "]
insist { subject.dependencies.sort } == ["rtxt-dep2 = 0.1", "rtxt-dep4"]
end
end
end
context "python_scripts_executable is set" do
it "should have scripts with a custom hashbang line" do
pending("Disabled on travis-ci becaulamese it always fails, and there is no way to debug it?") if is_travis
skip("Requires python3 executable") unless program_exists?("python3")
skip("setup.py-specific feature is no longer supported")
subject.attributes[:python_scripts_executable] = "fancypants"
# Newer versions of Django require Python 3.
@ -214,4 +233,85 @@ describe FPM::Package::Python do
insist { topline.chomp } == "#!fancypants"
end
end
context "when input is a name" do
it "should download from pypi" do
subject.input("click==8.3.0")
prefix = subject.attributes[:python_package_name_prefix]
insist { subject.name } == "#{prefix}-click"
insist { subject.version } == "8.3.0"
insist { subject.maintainer } == "Pallets <contact@palletsprojects.com>"
insist { subject.architecture } == "all"
insist { subject.dependencies } == [ ]
end
end
end # describe FPM::Package::Python
describe FPM::Package::Python::PythonMetadata do
context "processing simple examples" do
let(:text) {
[
"Metadata-Version: 2.4",
"Name: hello",
"Version: 1.0",
].join("\n") + "\n"
}
subject { described_class.from(text) }
it "should" do
insist { subject.name } == "hello"
insist { subject.version } == "1.0"
end
end
# Use a known METADATA file from a real Python package
context "when parsing Django 5.2.6's METADATA" do
let(:text) do
File.read(File.expand_path("../../fixtures/python/METADATA", File.dirname(__FILE__)))
end
expectations = {
"Metadata-Version" => "2.4",
"Name" => "Django",
"Version" => "5.2.6",
"Summary" => "A high-level Python web framework that encourages rapid development and clean, pragmatic design.",
"Author-email" => "Django Software Foundation <foundation@djangoproject.com>",
"License" => "BSD-3-Clause",
}
let(:parsed) { described_class.parse(text) }
let(:headers) { parsed[0] }
let(:body) { parsed[1] }
let(:metadata) { described_class.from(text) }
expectations.each do |field, value|
it "the #{field} field should be #{value.inspect}" do
insist { headers[field] } == value
end
end
it "should parse multivalue fields into an array value" do
insist { headers["Classifier"] }.is_a?(Enumerable)
insist { headers["Project-URL"] }.is_a?(Enumerable)
insist { headers["Requires-Dist"] }.is_a?(Enumerable)
insist { headers["Requires-Dist"] }.include?('asgiref>=3.8.1')
insist { headers["Requires-Dist"] }.include?('sqlparse>=0.3.1')
insist { headers["Requires-Dist"] }.include?('tzdata; sys_platform == "win32"')
insist { headers["Requires-Dist"] }.include?('argon2-cffi>=19.1.0; extra == "argon2"')
insist { headers["Requires-Dist"] }.include?('bcrypt; extra == "bcrypt"')
end
it "should provide correctly parsed values" do
insist { metadata.name } == "Django"
insist { metadata.version } == "5.2.6"
insist { metadata.summary } == "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
insist { metadata.license } == "BSD-3-Clause"
insist { metadata.homepage } == "https://www.djangoproject.com/"
end
end # parsing Django METADATA
end