lbry-android-sdk/p4a/pythonforandroid/pythonpackage.py
2022-11-29 15:35:24 -05:00

787 lines
30 KiB
Python

""" This module offers highlevel functions to get package metadata
like the METADATA file, the name, or a list of dependencies.
Usage examples:
# Getting package name from pip reference:
from pytonforandroid.pythonpackage import get_package_name
print(get_package_name("pillow"))
# Outputs: "Pillow" (note the spelling!)
# Getting package dependencies:
from pytonforandroid.pythonpackage import get_package_dependencies
print(get_package_dependencies("pep517"))
# Outputs: "['pytoml']"
# Get package name from arbitrary package source:
from pytonforandroid.pythonpackage import get_package_name
print(get_package_name("/some/local/project/folder/"))
# Outputs package name
NOTE:
Yes, this module doesn't fit well into python-for-android, but this
functionality isn't available ANYWHERE ELSE, and upstream (pip, ...)
currently has no interest in taking this over, so it has no other place
to go.
(Unless someone reading this puts it into yet another packaging lib)
Reference discussion/upstream inclusion attempt:
https://github.com/pypa/packaging-problems/issues/247
"""
import functools
import os
import shutil
import subprocess
import sys
import tarfile
import tempfile
import textwrap
import time
import zipfile
from io import open # needed for python 2
from urllib.parse import unquote as urlunquote
from urllib.parse import urlparse
import toml
from pep517.envbuild import BuildEnvironment
from pep517.wrappers import Pep517HookCaller
def transform_dep_for_pip(dependency):
if dependency.find("@") > 0 and (
dependency.find("@") < dependency.find("://") or
"://" not in dependency
):
# WORKAROUND FOR UPSTREAM BUG:
# https://github.com/pypa/pip/issues/6097
# (Please REMOVE workaround once that is fixed & released upstream!)
#
# Basically, setup_requires() can contain a format pip won't install
# from a requirements.txt (PEP 508 URLs).
# To avoid this, translate to an #egg= reference:
if dependency.endswith("#"):
dependency = dependency[:-1]
url = (dependency.partition("@")[2].strip().partition("#egg")[0] +
"#egg=" +
dependency.partition("@")[0].strip()
)
return url
return dependency
def extract_metainfo_files_from_package(
package,
output_folder,
debug=False
):
""" Extracts metdata files from the given package to the given folder,
which may be referenced in any way that is permitted in
a requirements.txt file or install_requires=[] listing.
Current supported metadata files that will be extracted:
- pytoml.yml (only if package wasn't obtained as wheel)
- METADATA
"""
if package is None:
raise ValueError("package cannot be None")
if not os.path.exists(output_folder) or os.path.isfile(output_folder):
raise ValueError("output folder needs to be existing folder")
if debug:
print("extract_metainfo_files_from_package: extracting for " +
"package: " + str(package))
# A temp folder for making a package copy in case it's a local folder,
# because extracting metadata might modify files
# (creating sdists/wheels...)
temp_folder = tempfile.mkdtemp(prefix="pythonpackage-package-copy-")
try:
# Package is indeed a folder! Get a temp copy to work on:
if is_filesystem_path(package):
shutil.copytree(
parse_as_folder_reference(package),
os.path.join(temp_folder, "package"),
ignore=shutil.ignore_patterns(".tox")
)
package = os.path.join(temp_folder, "package")
# Because PEP517 can be noisy and contextlib.redirect_* fails to
# contain it, we will run the actual analysis in a separate process:
try:
subprocess.check_output([
sys.executable,
"-c",
"import importlib\n"
"import json\n"
"import os\n"
"import sys\n"
"sys.path = [os.path.dirname(sys.argv[3])] + sys.path\n"
"m = importlib.import_module(\n"
" os.path.basename(sys.argv[3]).partition('.')[0]\n"
")\n"
"m._extract_metainfo_files_from_package_unsafe("
" sys.argv[1],"
" sys.argv[2],"
")",
package, output_folder, os.path.abspath(__file__)],
stderr=subprocess.STDOUT, # make sure stderr is muted.
cwd=os.path.join(os.path.dirname(__file__), "..")
)
except subprocess.CalledProcessError as e:
output = e.output.decode("utf-8", "replace")
if debug:
print("Got error obtaining meta info.")
print("Detail output:")
print(output)
print("End of Detail output.")
raise ValueError(
"failed to obtain meta info - "
"is '{}' a valid package? "
"Detailed output:\n{}".format(package, output)
)
finally:
shutil.rmtree(temp_folder)
def _get_system_python_executable():
""" Returns the path the system-wide python binary.
(In case we're running in a virtualenv or venv)
"""
# This function is required by get_package_as_folder() to work
# inside a virtualenv, since venv creation will fail with
# the virtualenv's local python binary.
# (venv/virtualenv incompatibility)
# Abort if not in virtualenv or venv:
if not hasattr(sys, "real_prefix") and (
not hasattr(sys, "base_prefix") or
os.path.normpath(sys.base_prefix) ==
os.path.normpath(sys.prefix)):
return sys.executable
# Extract prefix we need to look in:
if hasattr(sys, "real_prefix"):
search_prefix = sys.real_prefix # virtualenv
else:
search_prefix = sys.base_prefix # venv
def python_binary_from_folder(path):
def binary_is_usable(python_bin):
""" Helper function to see if a given binary name refers
to a usable python interpreter binary
"""
# Abort if path isn't present at all or a directory:
if not os.path.exists(
os.path.join(path, python_bin)
) or os.path.isdir(os.path.join(path, python_bin)):
return
# We should check file not found anyway trying to run it,
# since it might be a dead symlink:
try:
filenotfounderror = FileNotFoundError
except NameError: # Python 2
filenotfounderror = OSError
try:
# Run it and see if version output works with no error:
subprocess.check_output([
os.path.join(path, python_bin), "--version"
], stderr=subprocess.STDOUT)
return True
except (subprocess.CalledProcessError, filenotfounderror):
return False
python_name = "python" + sys.version
while (not binary_is_usable(python_name) and
python_name.find(".") > 0):
# Try less specific binary name:
python_name = python_name.rpartition(".")[0]
if binary_is_usable(python_name):
return os.path.join(path, python_name)
return None
# Return from sys.real_prefix if present:
result = python_binary_from_folder(search_prefix)
if result is not None:
return result
# Check out all paths in $PATH:
bad_candidates = []
good_candidates = []
ever_had_nonvenv_path = False
ever_had_path_starting_with_prefix = False
for p in os.environ.get("PATH", "").split(":"):
# Skip if not possibly the real system python:
if not os.path.normpath(p).startswith(
os.path.normpath(search_prefix)
):
continue
ever_had_path_starting_with_prefix = True
# First folders might be virtualenv/venv we want to avoid:
if not ever_had_nonvenv_path:
sep = os.path.sep
if (
("system32" not in p.lower() and
"usr" not in p and
not p.startswith("/opt/python")) or
{"home", ".tox"}.intersection(set(p.split(sep))) or
"users" in p.lower()
):
# Doesn't look like bog-standard system path.
if (p.endswith(os.path.sep + "bin") or
p.endswith(os.path.sep + "bin" + os.path.sep)):
# Also ends in "bin" -> likely virtualenv/venv.
# Add as unfavorable / end of candidates:
bad_candidates.append(p)
continue
ever_had_nonvenv_path = True
good_candidates.append(p)
# If we have a bad env with PATH not containing any reference to our
# real python (travis, why would you do that to me?) then just guess
# based from the search prefix location itself:
if not ever_had_path_starting_with_prefix:
# ... and yes we're scanning all the folders for that, it's dumb
# but i'm not aware of a better way: (@JonasT)
for root, dirs, files in os.walk(search_prefix, topdown=True):
for name in dirs:
bad_candidates.append(os.path.join(root, name))
# Sort candidates by length (to prefer shorter ones):
def candidate_cmp(a, b):
return len(a) - len(b)
good_candidates = sorted(
good_candidates, key=functools.cmp_to_key(candidate_cmp)
)
bad_candidates = sorted(
bad_candidates, key=functools.cmp_to_key(candidate_cmp)
)
# See if we can now actually find the system python:
for p in good_candidates + bad_candidates:
result = python_binary_from_folder(p)
if result is not None:
return result
raise RuntimeError(
"failed to locate system python in: {}"
" - checked candidates were: {}, {}"
.format(sys.real_prefix, good_candidates, bad_candidates)
)
def get_package_as_folder(dependency):
""" This function downloads the given package / dependency and extracts
the raw contents into a folder.
Afterwards, it returns a tuple with the type of distribution obtained,
and the temporary folder it extracted to. It is the caller's
responsibility to delete the returned temp folder after use.
Examples of returned values:
("source", "/tmp/pythonpackage-venv-e84toiwjw")
("wheel", "/tmp/pythonpackage-venv-85u78uj")
What the distribution type will be depends on what pip decides to
download.
"""
venv_parent = tempfile.mkdtemp(
prefix="pythonpackage-venv-"
)
try:
# Create a venv to install into:
try:
if int(sys.version.partition(".")[0]) < 3:
# Python 2.x has no venv.
subprocess.check_output([
sys.executable, # no venv conflict possible,
# -> no need to use system python
"-m", "virtualenv",
"--python=" + _get_system_python_executable(),
os.path.join(venv_parent, 'venv')
], cwd=venv_parent)
else:
# On modern Python 3, use venv.
subprocess.check_output([
_get_system_python_executable(), "-m", "venv",
os.path.join(venv_parent, 'venv')
], cwd=venv_parent)
except subprocess.CalledProcessError as e:
output = e.output.decode('utf-8', 'replace')
raise ValueError(
'venv creation unexpectedly ' +
'failed. error output: ' + str(output)
)
venv_path = os.path.join(venv_parent, "venv")
# Update pip and wheel in venv for latest feature support:
try:
filenotfounderror = FileNotFoundError
except NameError: # Python 2.
filenotfounderror = OSError
try:
subprocess.check_output([
os.path.join(venv_path, "bin", "pip"),
"install", "-U", "pip", "wheel",
])
except filenotfounderror:
raise RuntimeError(
"venv appears to be missing pip. "
"did we fail to use a proper system python??\n"
"system python path detected: {}\n"
"os.environ['PATH']: {}".format(
_get_system_python_executable(),
os.environ.get("PATH", "")
)
)
# Create download subfolder:
os.mkdir(os.path.join(venv_path, "download"))
# Write a requirements.txt with our package and download:
with open(os.path.join(venv_path, "requirements.txt"),
"w", encoding="utf-8"
) as f:
def to_unicode(s): # Needed for Python 2.
try:
return s.decode("utf-8")
except AttributeError:
return s
f.write(to_unicode(transform_dep_for_pip(dependency)))
try:
subprocess.check_output(
[
os.path.join(venv_path, "bin", "pip"),
"download", "--no-deps", "-r", "../requirements.txt",
"-d", os.path.join(venv_path, "download")
],
stderr=subprocess.STDOUT,
cwd=os.path.join(venv_path, "download")
)
except subprocess.CalledProcessError as e:
raise RuntimeError("package download failed: " + str(e.output))
if len(os.listdir(os.path.join(venv_path, "download"))) == 0:
# No download. This can happen if the dependency has a condition
# which prohibits install in our environment.
# (the "package ; ... conditional ... " type of condition)
return (None, None)
# Get the result and make sure it's an extracted directory:
result_folder_or_file = os.path.join(
venv_path, "download",
os.listdir(os.path.join(venv_path, "download"))[0]
)
dl_type = "source"
if not os.path.isdir(result_folder_or_file):
# Must be an archive.
if result_folder_or_file.endswith((".zip", ".whl")):
if result_folder_or_file.endswith(".whl"):
dl_type = "wheel"
with zipfile.ZipFile(result_folder_or_file) as f:
f.extractall(os.path.join(venv_path,
"download", "extracted"
))
result_folder_or_file = os.path.join(
venv_path, "download", "extracted"
)
elif result_folder_or_file.find(".tar.") > 0:
# Probably a tarball.
with tarfile.open(result_folder_or_file) as f:
f.extractall(os.path.join(venv_path,
"download", "extracted"
))
result_folder_or_file = os.path.join(
venv_path, "download", "extracted"
)
else:
raise RuntimeError(
"unknown archive or download " +
"type: " + str(result_folder_or_file)
)
# If the result is hidden away in an additional subfolder,
# descend into it:
while os.path.isdir(result_folder_or_file) and \
len(os.listdir(result_folder_or_file)) == 1 and \
os.path.isdir(os.path.join(
result_folder_or_file,
os.listdir(result_folder_or_file)[0]
)):
result_folder_or_file = os.path.join(
result_folder_or_file,
os.listdir(result_folder_or_file)[0]
)
# Copy result to new dedicated folder so we can throw away
# our entire virtualenv nonsense after returning:
result_path = tempfile.mkdtemp()
shutil.rmtree(result_path)
shutil.copytree(result_folder_or_file, result_path)
return (dl_type, result_path)
finally:
shutil.rmtree(venv_parent)
def _extract_metainfo_files_from_package_unsafe(
package,
output_path
):
# This is the unwrapped function that will
# 1. make lots of stdout/stderr noise
# 2. possibly modify files (if the package source is a local folder)
# Use extract_metainfo_files_from_package_folder instead which avoids
# these issues.
clean_up_path = False
path_type = "source"
path = parse_as_folder_reference(package)
if path is None:
# This is not a path. Download it:
(path_type, path) = get_package_as_folder(package)
if path_type is None:
# Download failed.
raise ValueError(
"cannot get info for this package, " +
"pip says it has no downloads (conditional dependency?)"
)
clean_up_path = True
try:
build_requires = []
metadata_path = None
if path_type != "wheel":
# We need to process this first to get the metadata.
# Ensure pyproject.toml is available (pep517 expects it)
if not os.path.exists(os.path.join(path, "pyproject.toml")):
with open(os.path.join(path, "pyproject.toml"), "w") as f:
f.write(textwrap.dedent(u"""\
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
"""))
# Copy the pyproject.toml:
shutil.copyfile(
os.path.join(path, 'pyproject.toml'),
os.path.join(output_path, 'pyproject.toml')
)
# Get build backend and requirements from pyproject.toml:
with open(os.path.join(path, 'pyproject.toml')) as f:
build_sys = toml.load(f)['build-system']
backend = build_sys["build-backend"]
build_requires.extend(build_sys["requires"])
# Get a virtualenv with build requirements and get all metadata:
env = BuildEnvironment()
metadata = None
with env:
hooks = Pep517HookCaller(path, backend)
env.pip_install(
[transform_dep_for_pip(req) for req in build_requires]
)
reqs = hooks.get_requires_for_build_wheel({})
env.pip_install([transform_dep_for_pip(req) for req in reqs])
try:
metadata = hooks.prepare_metadata_for_build_wheel(path)
except Exception: # sadly, pep517 has no good error here
pass
if metadata is not None:
metadata_path = os.path.join(
path, metadata, "METADATA"
)
else:
# This is a wheel, so metadata should be in *.dist-info folder:
metadata_path = os.path.join(
path,
[f for f in os.listdir(path) if f.endswith(".dist-info")][0],
"METADATA"
)
# Store type of metadata source. Can be "wheel", "source" for source
# distribution, and others get_package_as_folder() may support
# in the future.
with open(os.path.join(output_path, "metadata_source"), "w") as f:
try:
f.write(path_type)
except TypeError: # in python 2 path_type may be str/bytes:
f.write(path_type.decode("utf-8", "replace"))
# Copy the metadata file:
shutil.copyfile(metadata_path, os.path.join(output_path, "METADATA"))
finally:
if clean_up_path:
shutil.rmtree(path)
def is_filesystem_path(dep):
""" Convenience function around parse_as_folder_reference() to
check if a dependency refers to a folder path or something remote.
Returns True if local, False if remote.
"""
return (parse_as_folder_reference(dep) is not None)
def parse_as_folder_reference(dep):
""" See if a dependency reference refers to a folder path.
If it does, return the folder path (which parses and
resolves file:// urls in the process).
If it doesn't, return None.
"""
# Special case: pep508 urls
if dep.find("@") > 0 and (
(dep.find("@") < dep.find("/") or "/" not in dep) and
(dep.find("@") < dep.find(":") or ":" not in dep)
):
# This should be a 'pkgname @ https://...' style path, or
# 'pkname @ /local/file/path'.
return parse_as_folder_reference(dep.partition("@")[2].lstrip())
# Check if this is either not an url, or a file URL:
if dep.startswith(("/", "file://")) or (
dep.find("/") > 0 and
dep.find("://") < 0) or (dep in ["", "."]):
if dep.startswith("file://"):
dep = urlunquote(urlparse(dep).path)
return dep
return None
def _extract_info_from_package(dependency,
extract_type=None,
debug=False,
include_build_requirements=False
):
""" Internal function to extract metainfo from a package.
Currently supported info types:
- name
- dependencies (a list of dependencies)
"""
if debug:
print("_extract_info_from_package called with "
"extract_type={} include_build_requirements={}".format(
extract_type, include_build_requirements,
))
output_folder = tempfile.mkdtemp(prefix="pythonpackage-metafolder-")
try:
extract_metainfo_files_from_package(
dependency, output_folder, debug=debug
)
# Extract the type of data source we used to get the metadata:
with open(os.path.join(output_folder,
"metadata_source"), "r") as f:
metadata_source_type = f.read().strip()
# Extract main METADATA file:
with open(os.path.join(output_folder, "METADATA"),
"r", encoding="utf-8"
) as f:
# Get metadata and cut away description (is after 2 linebreaks)
metadata_entries = f.read().partition("\n\n")[0].splitlines()
if extract_type == "name":
name = None
for meta_entry in metadata_entries:
if meta_entry.lower().startswith("name:"):
return meta_entry.partition(":")[2].strip()
if name is None:
raise ValueError("failed to obtain package name")
return name
elif extract_type == "dependencies":
# First, make sure we don't attempt to return build requirements
# for wheels since they usually come without pyproject.toml
# and we haven't implemented another way to get them:
if include_build_requirements and \
metadata_source_type == "wheel":
if debug:
print("_extract_info_from_package: was called "
"with include_build_requirements=True on "
"package obtained as wheel, raising error...")
raise NotImplementedError(
"fetching build requirements for "
"wheels is not implemented"
)
# Get build requirements from pyproject.toml if requested:
requirements = []
if os.path.exists(os.path.join(output_folder,
'pyproject.toml')
) and include_build_requirements:
# Read build system from pyproject.toml file: (PEP518)
with open(os.path.join(output_folder, 'pyproject.toml')) as f:
build_sys = toml.load(f)['build-system']
if "requires" in build_sys:
requirements += build_sys["requires"]
elif include_build_requirements:
# For legacy packages with no pyproject.toml, we have to
# add setuptools as default build system.
requirements.append("setuptools")
# Add requirements from metadata:
requirements += [
entry.rpartition("Requires-Dist:")[2].strip()
for entry in metadata_entries
if entry.startswith("Requires-Dist")
]
return list(set(requirements)) # remove duplicates
finally:
shutil.rmtree(output_folder)
package_name_cache = dict()
def get_package_name(dependency,
use_cache=True):
def timestamp():
try:
return time.monotonic()
except AttributeError:
return time.time() # Python 2.
try:
value = package_name_cache[dependency]
if value[0] + 600.0 > timestamp() and use_cache:
return value[1]
except KeyError:
pass
result = _extract_info_from_package(dependency, extract_type="name")
package_name_cache[dependency] = (timestamp(), result)
return result
def get_package_dependencies(package,
recursive=False,
verbose=False,
include_build_requirements=False):
""" Obtain the dependencies from a package. Please note this
function is possibly SLOW, especially if you enable
the recursive mode.
"""
packages_processed = set()
package_queue = [package]
reqs = set()
reqs_as_names = set()
while len(package_queue) > 0:
current_queue = package_queue
package_queue = []
for package_dep in current_queue:
new_reqs = set()
if verbose:
print("get_package_dependencies: resolving dependency "
f"to package name: {package_dep}")
package = get_package_name(package_dep)
if package.lower() in packages_processed:
continue
if verbose:
print("get_package_dependencies: "
"processing package: {}".format(package))
print("get_package_dependencies: "
"Packages seen so far: {}".format(
packages_processed
))
packages_processed.add(package.lower())
# Use our regular folder processing to examine:
new_reqs = new_reqs.union(_extract_info_from_package(
package_dep, extract_type="dependencies",
debug=verbose,
include_build_requirements=include_build_requirements,
))
# Process new requirements:
if verbose:
print('get_package_dependencies: collected '
"deps of '{}': {}".format(
package_dep, str(new_reqs),
))
for new_req in new_reqs:
try:
req_name = get_package_name(new_req)
except ValueError as e:
if new_req.find(";") >= 0:
# Conditional dep where condition isn't met?
# --> ignore it
continue
if verbose:
print("get_package_dependencies: " +
"unexpected failure to get name " +
"of '" + str(new_req) + "': " +
str(e))
raise RuntimeError(
"failed to get " +
"name of dependency: " + str(e)
)
if req_name.lower() in reqs_as_names:
continue
if req_name.lower() not in packages_processed:
package_queue.append(new_req)
reqs.add(new_req)
reqs_as_names.add(req_name.lower())
# Bail out here if we're not scanning recursively:
if not recursive:
package_queue[:] = [] # wipe queue
break
if verbose:
print("get_package_dependencies: returning result: {}".format(reqs))
return reqs
def get_dep_names_of_package(
package,
keep_version_pins=False,
recursive=False,
verbose=False,
include_build_requirements=False
):
""" Gets the dependencies from the package in the given folder,
then attempts to deduce the actual package name resulting
from each dependency line, stripping away everything else.
"""
# First, obtain the dependencies:
dependencies = get_package_dependencies(
package, recursive=recursive, verbose=verbose,
include_build_requirements=include_build_requirements,
)
if verbose:
print("get_dep_names_of_package_folder: " +
"processing dependency list to names: " +
str(dependencies))
# Transform dependencies to their stripped down names:
# (they can still have version pins/restrictions, conditionals, ...)
dependency_names = set()
for dep in dependencies:
# If we are supposed to keep exact version pins, extract first:
pin_to_append = ""
if keep_version_pins and "(==" in dep and dep.endswith(")"):
# This is a dependency of the format: 'pkg (==1.0)'
pin_to_append = "==" + dep.rpartition("==")[2][:-1]
elif keep_version_pins and "==" in dep and not dep.endswith(")"):
# This is a dependency of the format: 'pkg==1.0'
pin_to_append = "==" + dep.rpartition("==")[2]
# Now get true (and e.g. case-corrected) dependency name:
dep_name = get_package_name(dep) + pin_to_append
dependency_names.add(dep_name)
return dependency_names