tools/python/packapp/__main__.py (171 lines of code) (raw):
#!/usr/bin/env python3
import argparse
import os
import os.path
import pathlib
import re
import shutil
import subprocess
import sys
import tempfile
import distlib.scripts
import distlib.wheel
from enum import IntEnum
_platform_map = {
'linux': 'manylinux1_x86_64',
'windows': 'win_amd64',
}
_wheel_file_pattern = r"""
^{namever}
((-(?P<build>\d[^-]*?))?-(?P<pyver>.+?)-(?P<abi>.+?)-(?P<plat>.+?)
\.whl)$
"""
class ExitCode(IntEnum):
success = 0
general_error = 1
native_deps_error = 4
def die(msg, exitcode=ExitCode.general_error):
print(f'ERROR: {msg}', file=sys.stderr)
sys.exit(int(exitcode))
def run(cmd, *, verbose=False, **kwargs):
if verbose:
stdout = stderr = None
else:
stdout = stderr = subprocess.PIPE
print(' '.join(cmd))
return subprocess.run(cmd, stdout=stdout, stderr=stderr, **kwargs)
def run_or_die(cmd, *, verbose=False, **kwargs):
try:
run(cmd, verbose=verbose, check=True, **kwargs)
except subprocess.CalledProcessError as e:
die(f'{cmd} failed with exit code {e.returncode}')
def main(argv=sys.argv[1:]):
args = parse_args(argv)
if not args.no_deps:
find_and_build_deps(args)
def find_and_build_deps(args):
app_path = pathlib.Path(args.path)
req_txt = app_path / 'requirements.txt'
if not req_txt.exists():
die('missing requirements.txt file. '
'If you do not have any requirements, please pass --no-deps.')
packages = []
# First, we need to figure out the complete list of dependencies
# without actually installing them. Use straight `pip download`
# for that.
with tempfile.TemporaryDirectory(prefix='azureworker') as td:
run_or_die([
sys.executable, '-m', 'pip', 'download', '-r', str(req_txt), '--dest', td
], verbose=args.verbose)
files = os.listdir(td)
for filename in files:
m = re.match(r'^(?P<name>.+?)-(?P<ver>.*?)-.*\.whl$', filename)
if m:
# This is a wheel.
packages.append((m.group('name'), m.group('ver')))
else:
# This is a sdist.
m = re.match(r'^(?P<namever>.+)(\.tar\.gz|\.tgz|\.zip)$',
filename)
if m:
name, _, ver = m.group('namever').rpartition('-')
if name and ver:
packages.append((name, ver))
# Now that we know all dependencies, download or build wheels
# for them for the correct platform and Python verison.
with tempfile.TemporaryDirectory(prefix='azureworker') as td:
for name, ver in packages:
ensure_wheel(name, ver, args=args, dest=td)
with tempfile.TemporaryDirectory(prefix='azureworkervenv') as venv:
venv = pathlib.Path(venv)
pyver = args.python_version
python = f'python{pyver[0]}.{pyver[1]}'
if args.platform == 'windows':
sp = venv / 'Lib' / 'site-packages'
headers = venv / 'Include'
scripts = venv / 'Scripts'
data = venv
elif args.platform == 'linux' and python == "python3.6":
sp = venv / 'lib' / python / 'site-packages'
headers = venv / 'include' / 'site' / python
scripts = venv / 'bin'
data = venv
elif args.platform == 'linux':
sp = venv / 'lib' / 'site-packages'
headers = venv / 'include' / 'site' / python
scripts = venv / 'bin'
data = venv
else:
die(f'unsupported platform: {args.platform}')
maker = distlib.scripts.ScriptMaker(None, None)
for filename in os.listdir(td):
if not filename.endswith('.whl'):
continue
wheel = distlib.wheel.Wheel(os.path.join(td, filename))
paths = {
'prefix': venv,
'purelib': sp,
'platlib': sp,
'headers': headers / wheel.name,
'scripts': scripts,
'data': data
}
for dn in paths.values():
os.makedirs(dn, exist_ok=True)
# print(paths, maker)
wheel.install(paths, maker)
for root, dirs, files in os.walk(venv):
for file in files:
src = os.path.join(root, file)
rpath = app_path / args.packages_dir_name / \
os.path.relpath(src, venv)
dir_name, _ = os.path.split(rpath)
os.makedirs(dir_name, exist_ok=True)
shutil.copyfile(src, rpath)
def ensure_wheel(name, version, args, dest):
cmd = [
'pip', 'download', '--no-deps', '--only-binary', ':all:',
'--platform', _platform_map.get(args.platform),
'--python-version', args.python_version,
'--implementation', 'cp',
'--abi', f'cp{args.python_version}m',
'--dest', dest,
f'{name}=={version}'
]
pip = run(cmd)
if pip.returncode != 0:
# No wheel for this package for this platform or Python version.
if not build_independent_wheel(name, version, args, dest):
build_binary_wheel(name, version, args, dest)
def build_independent_wheel(name, version, args, dest):
with tempfile.TemporaryDirectory(prefix='azureworker') as td:
cmd = [
'pip', 'wheel', '--no-deps', '--no-binary', ':all:',
'--wheel-dir', td,
f'{name}=={version}'
]
# First, try to build it as an independent wheel.
pip = run(cmd)
if pip.returncode != 0:
return False
wheel_re = _wheel_file_pattern.format(namever=f'{name}-[^-]*')
for filename in os.listdir(td):
m = re.match(wheel_re, filename, re.VERBOSE)
if m:
abi = m.group('abi')
platform = m.group('plat')
if abi == 'none' and platform == 'any':
# This is a universal wheel.
shutil.move(os.path.join(td, filename), dest)
return True
break
return False
def build_binary_wheel(name, version, args, dest):
die(f'cannot install {name}-{version} dependency: binary dependencies without wheels are not supported when building locally. '
f'Use the "--build remote" option to build dependencies on the Azure Functions build server, '
f'or "--build-native-deps" option to automatically build and configure the dependencies using a Docker container. '
f'More information at https://aka.ms/func-python-publish', ExitCode.native_deps_error)
def parse_args(argv):
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', default=False, action='store_true')
parser.add_argument('--platform', type=str)
parser.add_argument('--python-version', type=str)
parser.add_argument('--no-deps', default=False, action='store_true')
parser.add_argument('--packages-dir-name', type=str,
default='.python_packages',
help='folder to save packages in. '
'Default: .python_packages')
parser.add_argument('path', type=str,
help='Path to a function app to pack.')
args = parser.parse_args(argv)
if not args.platform:
die('missing required argument: --platform')
if not args.python_version:
die('missing required argument: --python-version')
return args
if __name__ == '__main__':
main()