From 9dff54a1f827ee2d40761fae72cd0c1b69489818 Mon Sep 17 00:00:00 2001 From: Chin Yeung Li Date: Thu, 17 Jun 2021 17:59:32 +0800 Subject: [PATCH 01/30] Create junction from Scripts to bin * This is handy for windows to have the same path as linux Signed-off-by: Chin Yeung Li --- configure.bat | 3 +++ 1 file changed, 3 insertions(+) diff --git a/configure.bat b/configure.bat index c12f937..bafa126 100644 --- a/configure.bat +++ b/configure.bat @@ -142,6 +142,9 @@ if %ERRORLEVEL% neq 0 ( %PIP_EXTRA_ARGS% ^ %CFG_REQUIREMENTS% +@rem # Create junction to bin to have the same directory between linux and windows +mklink /J %CFG_ROOT_DIR%\%VIRTUALENV_DIR%\bin %CFG_ROOT_DIR%\%VIRTUALENV_DIR%\Scripts + if %ERRORLEVEL% neq 0 ( exit /b %ERRORLEVEL% ) From 77ce5e4068eaa64b876ca267d09e1689fe67ae8f Mon Sep 17 00:00:00 2001 From: Jono Yang Date: Mon, 30 Aug 2021 17:40:27 -0700 Subject: [PATCH 02/30] Check for deps in local thirdparty directory #31 Signed-off-by: Jono Yang --- configure | 8 +++++--- configure.bat | 6 ++++++ thirdparty/README.rst | 2 ++ 3 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 thirdparty/README.rst diff --git a/configure b/configure index 25ab0ce..99bdf57 100755 --- a/configure +++ b/configure @@ -11,7 +11,7 @@ set -e #set -x ################################ -# A configuration script to set things up: +# A configuration script to set things up: # create a virtualenv and install or update thirdparty packages. # Source this script for initial configuration # Use configure --help for details @@ -50,9 +50,11 @@ VIRTUALENV_PYZ_URL=https://bootstrap.pypa.io/virtualenv.pyz CFG_ROOT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" CFG_BIN_DIR=$CFG_ROOT_DIR/$VIRTUALENV_DIR/bin +# Find packages from the local thirdparty directory or from pypi +PIP_EXTRA_ARGS="--find-links $CFG_ROOT_DIR/thirdparty" ################################ -# Set the quiet flag to empty if not defined +# Set the quiet flag to empty if not defined if [[ "$CFG_QUIET" == "" ]]; then CFG_QUIET=" " fi @@ -63,7 +65,7 @@ fi # Use environment variables or a file if available. # Otherwise the latest Python by default. if [[ "$PYTHON_EXECUTABLE" == "" ]]; then - # check for a file named PYTHON_EXECUTABLE + # check for a file named PYTHON_EXECUTABLE if [ -f "$CFG_ROOT_DIR/PYTHON_EXECUTABLE" ]; then PYTHON_EXECUTABLE=$(cat "$CFG_ROOT_DIR/PYTHON_EXECUTABLE") else diff --git a/configure.bat b/configure.bat index bafa126..be8f579 100644 --- a/configure.bat +++ b/configure.bat @@ -47,6 +47,12 @@ set CFG_ROOT_DIR=%~dp0 set "CFG_BIN_DIR=%CFG_ROOT_DIR%\%VIRTUALENV_DIR%\Scripts" +@rem ################################ +@rem # Thirdparty package locations and index handling +set "PIP_EXTRA_ARGS=--find-links %CFG_ROOT_DIR%\thirdparty" +@rem ################################ + + @rem ################################ @rem # Set the quiet flag to empty if not defined if not defined CFG_QUIET ( diff --git a/thirdparty/README.rst b/thirdparty/README.rst new file mode 100644 index 0000000..b31482f --- /dev/null +++ b/thirdparty/README.rst @@ -0,0 +1,2 @@ +Put your Python dependency wheels to be vendored in this directory. + From 1bcaaa574d4430ae363e66a3ace74ef3e4e8981b Mon Sep 17 00:00:00 2001 From: Jono Yang Date: Wed, 1 Sep 2021 15:59:49 -0700 Subject: [PATCH 03/30] Enforce use of requirements.txt #34 Signed-off-by: Jono Yang --- configure | 12 ++++++++---- configure.bat | 11 ++++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/configure b/configure index 99bdf57..66d939a 100755 --- a/configure +++ b/configure @@ -26,8 +26,8 @@ CLI_ARGS=$1 ################################ # Requirement arguments passed to pip and used by default or with --dev. -REQUIREMENTS="--editable ." -DEV_REQUIREMENTS="--editable .[testing]" +REQUIREMENTS="--editable . --constraint requirements.txt" +DEV_REQUIREMENTS="--editable .[testing] --constraint requirements.txt --constraint requirements-dev.txt" # where we create a virtualenv VIRTUALENV_DIR=tmp @@ -50,8 +50,12 @@ VIRTUALENV_PYZ_URL=https://bootstrap.pypa.io/virtualenv.pyz CFG_ROOT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" CFG_BIN_DIR=$CFG_ROOT_DIR/$VIRTUALENV_DIR/bin -# Find packages from the local thirdparty directory or from pypi -PIP_EXTRA_ARGS="--find-links $CFG_ROOT_DIR/thirdparty" +# Find packages from the local thirdparty directory or from thirdparty.aboutcode.org +PIP_EXTRA_ARGS="--find-links $CFG_ROOT_DIR/thirdparty --find-links https://thirdparty.aboutcode.org/pypi" + +if [[ -f "$CFG_ROOT_DIR/requirements.txt" ]] && [[ -f "$CFG_ROOT_DIR/requirements-dev.txt" ]]; then + PIP_EXTRA_ARGS+=" --no-index" +fi ################################ # Set the quiet flag to empty if not defined diff --git a/configure.bat b/configure.bat index be8f579..75cab5f 100644 --- a/configure.bat +++ b/configure.bat @@ -24,8 +24,8 @@ @rem ################################ @rem # Requirement arguments passed to pip and used by default or with --dev. -set "REQUIREMENTS=--editable ." -set "DEV_REQUIREMENTS=--editable .[testing]" +set "REQUIREMENTS=--editable . --constraint requirements.txt" +set "DEV_REQUIREMENTS=--editable .[testing] --constraint requirements.txt --constraint requirements-dev.txt" @rem # where we create a virtualenv set "VIRTUALENV_DIR=tmp" @@ -49,7 +49,12 @@ set "CFG_BIN_DIR=%CFG_ROOT_DIR%\%VIRTUALENV_DIR%\Scripts" @rem ################################ @rem # Thirdparty package locations and index handling -set "PIP_EXTRA_ARGS=--find-links %CFG_ROOT_DIR%\thirdparty" +if exist ""%CFG_ROOT_DIR%\requirements.txt"" if exist ""%CFG_ROOT_DIR%\requirements-dev.txt"" ( + set "INDEX_ARG= --no-index" +) else ( + set "INDEX_ARG= " +) +set "PIP_EXTRA_ARGS=--find-links %CFG_ROOT_DIR%\thirdparty --find-links https://thirdparty.aboutcode.org/pypi" & %INDEX_ARG% @rem ################################ From e9067c81d14d07ec2dafc732292a078d0519c885 Mon Sep 17 00:00:00 2001 From: Jono Yang Date: Wed, 1 Sep 2021 18:04:25 -0700 Subject: [PATCH 04/30] Add scripts from scancode-toolkit/etc/release/ #33 Signed-off-by: Jono Yang --- etc/scripts/bootstrap.py | 212 ++ etc/scripts/build_wheels.py | 97 + etc/scripts/check_thirdparty.py | 32 + etc/scripts/fetch_requirements.py | 145 + etc/scripts/fix_thirdparty.py | 81 + etc/scripts/gen_requirements.py | 43 + etc/scripts/gen_requirements_dev.py | 55 + .../test_utils_pip_compatibility_tags.py | 128 + ...test_utils_pip_compatibility_tags.py.ABOUT | 14 + etc/scripts/test_utils_pypi_supported_tags.py | 91 + .../test_utils_pypi_supported_tags.py.ABOUT | 17 + etc/scripts/utils_dejacode.py | 213 ++ etc/scripts/utils_pip_compatibility_tags.py | 192 ++ .../utils_pip_compatibility_tags.py.ABOUT | 14 + etc/scripts/utils_pypi_supported_tags.py | 109 + .../utils_pypi_supported_tags.py.ABOUT | 17 + etc/scripts/utils_requirements.py | 103 + etc/scripts/utils_thirdparty.py | 2940 +++++++++++++++++ etc/scripts/utils_thirdparty.py.ABOUT | 15 + 19 files changed, 4518 insertions(+) create mode 100644 etc/scripts/bootstrap.py create mode 100644 etc/scripts/build_wheels.py create mode 100644 etc/scripts/check_thirdparty.py create mode 100644 etc/scripts/fetch_requirements.py create mode 100644 etc/scripts/fix_thirdparty.py create mode 100644 etc/scripts/gen_requirements.py create mode 100644 etc/scripts/gen_requirements_dev.py create mode 100644 etc/scripts/test_utils_pip_compatibility_tags.py create mode 100644 etc/scripts/test_utils_pip_compatibility_tags.py.ABOUT create mode 100644 etc/scripts/test_utils_pypi_supported_tags.py create mode 100644 etc/scripts/test_utils_pypi_supported_tags.py.ABOUT create mode 100644 etc/scripts/utils_dejacode.py create mode 100644 etc/scripts/utils_pip_compatibility_tags.py create mode 100644 etc/scripts/utils_pip_compatibility_tags.py.ABOUT create mode 100644 etc/scripts/utils_pypi_supported_tags.py create mode 100644 etc/scripts/utils_pypi_supported_tags.py.ABOUT create mode 100644 etc/scripts/utils_requirements.py create mode 100644 etc/scripts/utils_thirdparty.py create mode 100644 etc/scripts/utils_thirdparty.py.ABOUT diff --git a/etc/scripts/bootstrap.py b/etc/scripts/bootstrap.py new file mode 100644 index 0000000..54701f6 --- /dev/null +++ b/etc/scripts/bootstrap.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# ScanCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +import itertools + +import click + +import utils_thirdparty +from utils_thirdparty import Environment +from utils_thirdparty import PypiPackage + + +@click.command() + +@click.option('-r', '--requirements-file', + type=click.Path(exists=True, readable=True, path_type=str, dir_okay=False), + metavar='FILE', + multiple=True, + default=['requirements.txt'], + show_default=True, + help='Path to the requirements file(s) to use for thirdparty packages.', +) +@click.option('-d', '--thirdparty-dir', + type=click.Path(exists=True, readable=True, path_type=str, file_okay=False), + metavar='DIR', + default=utils_thirdparty.THIRDPARTY_DIR, + show_default=True, + help='Path to the thirdparty directory where wheels are built and ' + 'sources, ABOUT and LICENSE files fetched.', +) +@click.option('-p', '--python-version', + type=click.Choice(utils_thirdparty.PYTHON_VERSIONS), + metavar='PYVER', + default=utils_thirdparty.PYTHON_VERSIONS, + show_default=True, + multiple=True, + help='Python version(s) to use for this build.', +) +@click.option('-o', '--operating-system', + type=click.Choice(utils_thirdparty.PLATFORMS_BY_OS), + metavar='OS', + default=tuple(utils_thirdparty.PLATFORMS_BY_OS), + multiple=True, + show_default=True, + help='OS(ses) to use for this build: one of linux, mac or windows.', +) +@click.option('-l', '--latest-version', + is_flag=True, + help='Get the latest version of all packages, ignoring version specifiers.', +) +@click.option('--sync-dejacode', + is_flag=True, + help='Synchronize packages with DejaCode.', +) +@click.option('--with-deps', + is_flag=True, + help='Also include all dependent wheels.', +) +@click.help_option('-h', '--help') +def bootstrap( + requirements_file, + thirdparty_dir, + python_version, + operating_system, + with_deps, + latest_version, + sync_dejacode, + build_remotely=False, +): + """ + Boostrap a thirdparty Python packages directory from pip requirements. + + Fetch or build to THIRDPARTY_DIR all the wheels and source distributions for + the pip ``--requirement-file`` requirements FILE(s). Build wheels compatible + with all the provided ``--python-version`` PYVER(s) and ```--operating_system`` + OS(s) defaulting to all supported combinations. Create or fetch .ABOUT and + .LICENSE files. + + Optionally ignore version specifiers and use the ``--latest-version`` + of everything. + + Sources and wheels are fetched with attempts first from PyPI, then our remote repository. + If missing wheels are built as needed. + """ + # rename variables for clarity since these are lists + requirements_files = requirements_file + python_versions = python_version + operating_systems = operating_system + + # create the environments we need + evts = itertools.product(python_versions, operating_systems) + environments = [Environment.from_pyver_and_os(pyv, os) for pyv, os in evts] + + # collect all packages to process from requirements files + # this will fail with an exception if there are packages we cannot find + + required_name_versions = set() + + for req_file in requirements_files: + nvs = utils_thirdparty.load_requirements( + requirements_file=req_file, force_pinned=False) + required_name_versions.update(nvs) + if latest_version: + required_name_versions = set((name, None) for name, _ver in required_name_versions) + + print(f'PROCESSING {len(required_name_versions)} REQUIREMENTS in {len(requirements_files)} FILES') + + # fetch all available wheels, keep track of missing + # start with local, then remote, then PyPI + + print('==> COLLECTING ALREADY LOCALLY AVAILABLE REQUIRED WHEELS') + # list of all the wheel filenames either pre-existing, fetched or built + # updated as we progress + available_wheel_filenames = [] + + local_packages_by_namever = { + (p.name, p.version): p + for p in utils_thirdparty.get_local_packages(directory=thirdparty_dir) + } + + # list of (name, version, environment) not local and to fetch + name_version_envt_to_fetch = [] + + # start with a local check + for (name, version), envt in itertools.product(required_name_versions, environments): + local_pack = local_packages_by_namever.get((name, version,)) + if local_pack: + supported_wheels = list(local_pack.get_supported_wheels(environment=envt)) + if supported_wheels: + available_wheel_filenames.extend(w.filename for w in supported_wheels) + print(f'====> No fetch or build needed. ' + f'Local wheel already available for {name}=={version} ' + f'on os: {envt.operating_system} for Python: {envt.python_version}') + continue + + name_version_envt_to_fetch.append((name, version, envt,)) + + print(f'==> TRYING TO FETCH #{len(name_version_envt_to_fetch)} REQUIRED WHEELS') + + # list of (name, version, environment) not fetch and to build + name_version_envt_to_build = [] + + # then check if the wheel can be fetched without building from remote and Pypi + for name, version, envt in name_version_envt_to_fetch: + + fetched_fwn = utils_thirdparty.fetch_package_wheel( + name=name, + version=version, + environment=envt, + dest_dir=thirdparty_dir, + ) + + if fetched_fwn: + available_wheel_filenames.append(fetched_fwn) + else: + name_version_envt_to_build.append((name, version, envt,)) + + # At this stage we have all the wheels we could obtain without building + for name, version, envt in name_version_envt_to_build: + print(f'====> Need to build wheels for {name}=={version} on os: ' + f'{envt.operating_system} for Python: {envt.python_version}') + + packages_and_envts_to_build = [ + (PypiPackage(name, version), envt) + for name, version, envt in name_version_envt_to_build + ] + + print(f'==> BUILDING #{len(packages_and_envts_to_build)} MISSING WHEELS') + + package_envts_not_built, wheel_filenames_built = utils_thirdparty.build_missing_wheels( + packages_and_envts=packages_and_envts_to_build, + build_remotely=build_remotely, + with_deps=with_deps, + dest_dir=thirdparty_dir, +) + if wheel_filenames_built: + available_wheel_filenames.extend(available_wheel_filenames) + + for pack, envt in package_envts_not_built: + print( + f'====> FAILED to build any wheel for {pack.name}=={pack.version} ' + f'on os: {envt.operating_system} for Python: {envt.python_version}' + ) + + print(f'==> FETCHING SOURCE DISTRIBUTIONS') + # fetch all sources, keep track of missing + # This is a list of (name, version) + utils_thirdparty.fetch_missing_sources(dest_dir=thirdparty_dir) + + print(f'==> FETCHING ABOUT AND LICENSE FILES') + utils_thirdparty.add_fetch_or_update_about_and_license_files(dest_dir=thirdparty_dir) + + ############################################################################ + if sync_dejacode: + print(f'==> SYNC WITH DEJACODE') + # try to fetch from DejaCode any missing ABOUT + # create all missing DejaCode packages + pass + + utils_thirdparty.find_problems(dest_dir=thirdparty_dir) + + +if __name__ == '__main__': + bootstrap() diff --git a/etc/scripts/build_wheels.py b/etc/scripts/build_wheels.py new file mode 100644 index 0000000..416adc7 --- /dev/null +++ b/etc/scripts/build_wheels.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# ScanCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# +import click + +import utils_thirdparty + + +@click.command() + +@click.option('-n', '--name', + type=str, + metavar='PACKAGE_NAME', + required=True, + help='Python package name to add or build.', +) +@click.option('-v', '--version', + type=str, + default=None, + metavar='VERSION', + help='Python package version to add or build.', +) +@click.option('-d', '--thirdparty-dir', + type=click.Path(exists=True, readable=True, path_type=str, file_okay=False), + metavar='DIR', + default=utils_thirdparty.THIRDPARTY_DIR, + show_default=True, + help='Path to the thirdparty directory where wheels are built.', +) +@click.option('-p', '--python-version', + type=click.Choice(utils_thirdparty.PYTHON_VERSIONS), + metavar='PYVER', + default=utils_thirdparty.PYTHON_VERSIONS, + show_default=True, + multiple=True, + help='Python version to use for this build.', +) +@click.option('-o', '--operating-system', + type=click.Choice(utils_thirdparty.PLATFORMS_BY_OS), + metavar='OS', + default=tuple(utils_thirdparty.PLATFORMS_BY_OS), + multiple=True, + show_default=True, + help='OS to use for this build: one of linux, mac or windows.', +) +@click.option('--build-remotely', + is_flag=True, + help='Build missing wheels remotely.', +) +@click.option('--with-deps', + is_flag=True, + help='Also include all dependent wheels.', +) +@click.option('--verbose', + is_flag=True, + help='Provide verbose output.', +) +@click.help_option('-h', '--help') +def build_wheels( + name, + version, + thirdparty_dir, + python_version, + operating_system, + with_deps, + build_remotely, + verbose, +): + """ + Build to THIRDPARTY_DIR all the wheels for the Python PACKAGE_NAME and + optional VERSION. Build wheels compatible with all the `--python-version` + PYVER(s) and `--operating_system` OS(s). + + Build native wheels remotely if needed when `--build-remotely` and include + all dependencies with `--with-deps`. + """ + utils_thirdparty.add_or_upgrade_built_wheels( + name=name, + version=version, + python_versions=python_version, + operating_systems=operating_system, + dest_dir=thirdparty_dir, + build_remotely=build_remotely, + with_deps=with_deps, + verbose=verbose, + ) + + +if __name__ == '__main__': + build_wheels() diff --git a/etc/scripts/check_thirdparty.py b/etc/scripts/check_thirdparty.py new file mode 100644 index 0000000..b29ce2b --- /dev/null +++ b/etc/scripts/check_thirdparty.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# ScanCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# +import click + +import utils_thirdparty + + +@click.command() + +@click.option('-d', '--thirdparty-dir', + type=click.Path(exists=True, readable=True, path_type=str, file_okay=False), + required=True, + help='Path to the thirdparty directory to check.', +) +@click.help_option('-h', '--help') +def check_thirdparty_dir(thirdparty_dir): + """ + Check a thirdparty directory for problems. + """ + utils_thirdparty.find_problems(dest_dir=thirdparty_dir) + + +if __name__ == '__main__': + check_thirdparty_dir() diff --git a/etc/scripts/fetch_requirements.py b/etc/scripts/fetch_requirements.py new file mode 100644 index 0000000..dfd202a --- /dev/null +++ b/etc/scripts/fetch_requirements.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# ScanCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# +import itertools + +import click + +import utils_thirdparty + + +@click.command() + +@click.option('-r', '--requirements-file', + type=click.Path(exists=True, readable=True, path_type=str, dir_okay=False), + metavar='FILE', + multiple=True, + default=['requirements.txt'], + show_default=True, + help='Path to the requirements file to use for thirdparty packages.', +) +@click.option('-d', '--thirdparty-dir', + type=click.Path(exists=True, readable=True, path_type=str, file_okay=False), + metavar='DIR', + default=utils_thirdparty.THIRDPARTY_DIR, + show_default=True, + help='Path to the thirdparty directory.', +) +@click.option('-p', '--python-version', + type=click.Choice(utils_thirdparty.PYTHON_VERSIONS), + metavar='INT', + multiple=True, + default=['36'], + show_default=True, + help='Python version to use for this build.', +) +@click.option('-o', '--operating-system', + type=click.Choice(utils_thirdparty.PLATFORMS_BY_OS), + metavar='OS', + multiple=True, + default=['linux'], + show_default=True, + help='OS to use for this build: one of linux, mac or windows.', +) +@click.option('-s', '--with-sources', + is_flag=True, + help='Fetch the corresponding source distributions.', +) +@click.option('-a', '--with-about', + is_flag=True, + help='Fetch the corresponding ABOUT and LICENSE files.', +) +@click.option('--allow-unpinned', + is_flag=True, + help='Allow requirements without pinned versions.', +) +@click.option('-s', '--only-sources', + is_flag=True, + help='Fetch only the corresponding source distributions.', +) +@click.option('-u', '--remote-links-url', + type=str, + metavar='URL', + default=utils_thirdparty.REMOTE_LINKS_URL, + show_default=True, + help='URL to a PyPI-like links web site. ' + 'Or local path to a directory with wheels.', +) + +@click.help_option('-h', '--help') +def fetch_requirements( + requirements_file, + thirdparty_dir, + python_version, + operating_system, + with_sources, + with_about, + allow_unpinned, + only_sources, + remote_links_url=utils_thirdparty.REMOTE_LINKS_URL, +): + """ + Fetch and save to THIRDPARTY_DIR all the required wheels for pinned + dependencies found in the `--requirement` FILE requirements file(s). Only + fetch wheels compatible with the provided `--python-version` and + `--operating-system`. + Also fetch the corresponding .ABOUT, .LICENSE and .NOTICE files together + with a virtualenv.pyz app. + + Use exclusively wheel not from PyPI but rather found in the PyPI-like link + repo ``remote_links_url`` if this is a URL. Treat this ``remote_links_url`` + as a local directory path to a wheels directory if this is not a a URL. + """ + + # fetch wheels + python_versions = python_version + operating_systems = operating_system + requirements_files = requirements_file + + if not only_sources: + envs = itertools.product(python_versions, operating_systems) + envs = (utils_thirdparty.Environment.from_pyver_and_os(pyv, os) for pyv, os in envs) + + for env, reqf in itertools.product(envs, requirements_files): + + for package, error in utils_thirdparty.fetch_wheels( + environment=env, + requirements_file=reqf, + allow_unpinned=allow_unpinned, + dest_dir=thirdparty_dir, + remote_links_url=remote_links_url, + ): + if error: + print('Failed to fetch wheel:', package, ':', error) + + # optionally fetch sources + if with_sources or only_sources: + + for reqf in requirements_files: + for package, error in utils_thirdparty.fetch_sources( + requirements_file=reqf, + allow_unpinned=allow_unpinned, + dest_dir=thirdparty_dir, + remote_links_url=remote_links_url, + ): + if error: + print('Failed to fetch source:', package, ':', error) + + if with_about: + utils_thirdparty.add_fetch_or_update_about_and_license_files(dest_dir=thirdparty_dir) + utils_thirdparty.find_problems( + dest_dir=thirdparty_dir, + report_missing_sources=with_sources or only_sources, + report_missing_wheels=not only_sources, + ) + + +if __name__ == '__main__': + fetch_requirements() diff --git a/etc/scripts/fix_thirdparty.py b/etc/scripts/fix_thirdparty.py new file mode 100644 index 0000000..b74b497 --- /dev/null +++ b/etc/scripts/fix_thirdparty.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# ScanCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# +import click + +import utils_thirdparty + + +@click.command() + +@click.option('-d', '--thirdparty-dir', + type=click.Path(exists=True, readable=True, path_type=str, file_okay=False), + required=True, + help='Path to the thirdparty directory to fix.', +) +@click.option('--build-wheels', + is_flag=True, + help='Build all missing wheels .', +) +@click.option('--build-remotely', + is_flag=True, + help='Build missing wheels remotely.', +) +@click.help_option('-h', '--help') +def fix_thirdparty_dir( + thirdparty_dir, + build_wheels, + build_remotely, +): + """ + Fix a thirdparty directory of dependent package wheels and sdist. + + Multiple fixes are applied: + - fetch or build missing binary wheels + - fetch missing source distributions + - derive, fetch or add missing ABOUT files + - fetch missing .LICENSE and .NOTICE files + - remove outdated package versions and the ABOUT, .LICENSE and .NOTICE files + + Optionally build missing binary wheels for all supported OS and Python + version combos locally or remotely. + """ + print('***FETCH*** MISSING WHEELS') + package_envts_not_fetched = utils_thirdparty.fetch_missing_wheels(dest_dir=thirdparty_dir) + print('***FETCH*** MISSING SOURCES') + src_name_ver_not_fetched = utils_thirdparty.fetch_missing_sources(dest_dir=thirdparty_dir) + + package_envts_not_built = [] + if build_wheels: + print('***BUILD*** MISSING WHEELS') + package_envts_not_built, _wheel_filenames_built = utils_thirdparty.build_missing_wheels( + packages_and_envts=package_envts_not_fetched, + build_remotely=build_remotely, + dest_dir=thirdparty_dir, + ) + + print('***ADD*** ABOUT AND LICENSES') + utils_thirdparty.add_fetch_or_update_about_and_license_files(dest_dir=thirdparty_dir) + + # report issues + for name, version in src_name_ver_not_fetched: + print(f'{name}=={version}: Failed to fetch source distribution.') + + for package, envt in package_envts_not_built: + print( + f'{package.name}=={package.version}: Failed to build wheel ' + f'on {envt.operating_system} for Python {envt.python_version}') + + print('***FIND PROBLEMS***') + utils_thirdparty.find_problems(dest_dir=thirdparty_dir) + + +if __name__ == '__main__': + fix_thirdparty_dir() diff --git a/etc/scripts/gen_requirements.py b/etc/scripts/gen_requirements.py new file mode 100644 index 0000000..c917c87 --- /dev/null +++ b/etc/scripts/gen_requirements.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# ScanCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# +import click +import utils_requirements + + +@click.command() + +@click.option('-s', '--site-packages-dir', + type=click.Path(exists=True, readable=True, path_type=str, file_okay=False, resolve_path=True), + required=True, + metavar='DIR', + help='Path to the "site-packages" directory where wheels are installed such as lib/python3.6/site-packages', +) +@click.option('-r', '--requirements-file', + type=click.Path(path_type=str, dir_okay=False), + metavar='FILE', + default='requirements.txt', + show_default=True, + help='Path to the requirements file to update or create.', +) +@click.help_option('-h', '--help') +def gen_requirements(site_packages_dir, requirements_file): + """ + Create or replace the `--requirements-file` file FILE requirements file with all + locally installed Python packages.all Python packages found installed in `--site-packages-dir` + """ + utils_requirements.lock_requirements( + requirements_file=requirements_file, + site_packages_dir=site_packages_dir, + ) + + +if __name__ == '__main__': + gen_requirements() diff --git a/etc/scripts/gen_requirements_dev.py b/etc/scripts/gen_requirements_dev.py new file mode 100644 index 0000000..91e0ce6 --- /dev/null +++ b/etc/scripts/gen_requirements_dev.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# ScanCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# +import click +import utils_requirements + + +@click.command() + +@click.option('-s', '--site-packages-dir', + type=click.Path(exists=True, readable=True, path_type=str, file_okay=False, resolve_path=True), + required=True, + metavar='DIR', + help='Path to the "site-packages" directory where wheels are installed such as lib/python3.6/site-packages', +) +@click.option('-d', '--dev-requirements-file', + type=click.Path(path_type=str, dir_okay=False), + metavar='FILE', + default='requirements-dev.txt', + show_default=True, + help='Path to the dev requirements file to update or create.', +) +@click.option('-r', '--main-requirements-file', + type=click.Path(path_type=str, dir_okay=False), + default='requirements.txt', + metavar='FILE', + show_default=True, + help='Path to the main requirements file. Its requirements will be excluded ' + 'from the generated dev requirements.', +) +@click.help_option('-h', '--help') +def gen_dev_requirements(site_packages_dir, dev_requirements_file, main_requirements_file): + """ + Create or overwrite the `--dev-requirements-file` pip requirements FILE with + all Python packages found installed in `--site-packages-dir`. Exclude + package names also listed in the --main-requirements-file pip requirements + FILE (that are assume to the production requirements and therefore to always + be present in addition to the development requirements). + """ + utils_requirements.lock_dev_requirements( + dev_requirements_file=dev_requirements_file, + main_requirements_file=main_requirements_file, + site_packages_dir=site_packages_dir + ) + + +if __name__ == '__main__': + gen_dev_requirements() diff --git a/etc/scripts/test_utils_pip_compatibility_tags.py b/etc/scripts/test_utils_pip_compatibility_tags.py new file mode 100644 index 0000000..30c4dda --- /dev/null +++ b/etc/scripts/test_utils_pip_compatibility_tags.py @@ -0,0 +1,128 @@ +"""Generate and work with PEP 425 Compatibility Tags. + +copied from pip-20.3.1 pip/tests/unit/test_utils_compatibility_tags.py +download_url: https://raw.githubusercontent.com/pypa/pip/20.3.1/tests/unit/test_utils_compatibility_tags.py + +Copyright (c) 2008-2020 The pip developers (see AUTHORS.txt file) + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +""" + +from unittest.mock import patch +import sysconfig + +import pytest + +import utils_pip_compatibility_tags + + +@pytest.mark.parametrize('version_info, expected', [ + ((2,), '2'), + ((2, 8), '28'), + ((3,), '3'), + ((3, 6), '36'), + # Test a tuple of length 3. + ((3, 6, 5), '36'), + # Test a 2-digit minor version. + ((3, 10), '310'), +]) +def test_version_info_to_nodot(version_info, expected): + actual = pip_compatibility_tags.version_info_to_nodot(version_info) + assert actual == expected + + +class Testcompatibility_tags(object): + + def mock_get_config_var(self, **kwd): + """ + Patch sysconfig.get_config_var for arbitrary keys. + """ + get_config_var = sysconfig.get_config_var + + def _mock_get_config_var(var): + if var in kwd: + return kwd[var] + return get_config_var(var) + + return _mock_get_config_var + + def test_no_hyphen_tag(self): + """ + Test that no tag contains a hyphen. + """ + import pip._internal.utils.compatibility_tags + + mock_gcf = self.mock_get_config_var(SOABI='cpython-35m-darwin') + + with patch('sysconfig.get_config_var', mock_gcf): + supported = pip._internal.utils.compatibility_tags.get_supported() + + for tag in supported: + assert '-' not in tag.interpreter + assert '-' not in tag.abi + assert '-' not in tag.platform + + +class TestManylinux2010Tags(object): + + @pytest.mark.parametrize("manylinux2010,manylinux1", [ + ("manylinux2010_x86_64", "manylinux1_x86_64"), + ("manylinux2010_i686", "manylinux1_i686"), + ]) + def test_manylinux2010_implies_manylinux1(self, manylinux2010, manylinux1): + """ + Specifying manylinux2010 implies manylinux1. + """ + groups = {} + supported = pip_compatibility_tags.get_supported(platforms=[manylinux2010]) + for tag in supported: + groups.setdefault( + (tag.interpreter, tag.abi), [] + ).append(tag.platform) + + for arches in groups.values(): + if arches == ['any']: + continue + assert arches[:2] == [manylinux2010, manylinux1] + + +class TestManylinux2014Tags(object): + + @pytest.mark.parametrize("manylinuxA,manylinuxB", [ + ("manylinux2014_x86_64", ["manylinux2010_x86_64", "manylinux1_x86_64"]), + ("manylinux2014_i686", ["manylinux2010_i686", "manylinux1_i686"]), + ]) + def test_manylinuxA_implies_manylinuxB(self, manylinuxA, manylinuxB): + """ + Specifying manylinux2014 implies manylinux2010/manylinux1. + """ + groups = {} + supported = pip_compatibility_tags.get_supported(platforms=[manylinuxA]) + for tag in supported: + groups.setdefault( + (tag.interpreter, tag.abi), [] + ).append(tag.platform) + + expected_arches = [manylinuxA] + expected_arches.extend(manylinuxB) + for arches in groups.values(): + if arches == ['any']: + continue + assert arches[:3] == expected_arches diff --git a/etc/scripts/test_utils_pip_compatibility_tags.py.ABOUT b/etc/scripts/test_utils_pip_compatibility_tags.py.ABOUT new file mode 100644 index 0000000..07eee35 --- /dev/null +++ b/etc/scripts/test_utils_pip_compatibility_tags.py.ABOUT @@ -0,0 +1,14 @@ +about_resource: test_utils_pip_compatibility_tags.py + +type: github +namespace: pypa +name: pip +version: 20.3.1 +subpath: tests/unit/test_utils_compatibility_tags.py + +package_url: pkg:github/pypa/pip@20.3.1#tests/unit/test_utils_compatibility_tags.py + +download_url: https://raw.githubusercontent.com/pypa/pip/20.3.1/tests/unit/test_utils_compatibility_tags.py +copyright: Copyright (c) 2008-2020 The pip developers (see AUTHORS.txt file) +license_expression: mit +notes: subset copied from pip for tag handling diff --git a/etc/scripts/test_utils_pypi_supported_tags.py b/etc/scripts/test_utils_pypi_supported_tags.py new file mode 100644 index 0000000..9ad68b2 --- /dev/null +++ b/etc/scripts/test_utils_pypi_supported_tags.py @@ -0,0 +1,91 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from utils_pypi_supported_tags import validate_platforms_for_pypi + +""" +Wheel platform checking tests + +Copied and modified on 2020-12-24 from +https://github.com/pypa/warehouse/blob/37a83dd342d9e3b3ab4f6bde47ca30e6883e2c4d/tests/unit/forklift/test_legacy.py +""" + + +def validate_wheel_filename_for_pypi(filename): + """ + Validate if the filename is a PyPI/warehouse-uploadable wheel file name + with supported platform tags. Return a list of unsupported platform tags or + an empty list if all tags are supported. + """ + from utils_thirdparty import Wheel + wheel = Wheel.from_filename(filename) + return validate_platforms_for_pypi(wheel.platforms) + + +@pytest.mark.parametrize( + "plat", + [ + "any", + "win32", + "win_amd64", + "win_ia64", + "manylinux1_i686", + "manylinux1_x86_64", + "manylinux2010_i686", + "manylinux2010_x86_64", + "manylinux2014_i686", + "manylinux2014_x86_64", + "manylinux2014_aarch64", + "manylinux2014_armv7l", + "manylinux2014_ppc64", + "manylinux2014_ppc64le", + "manylinux2014_s390x", + "manylinux_2_5_i686", + "manylinux_2_12_x86_64", + "manylinux_2_17_aarch64", + "manylinux_2_17_armv7l", + "manylinux_2_17_ppc64", + "manylinux_2_17_ppc64le", + "manylinux_3_0_s390x", + "macosx_10_6_intel", + "macosx_10_13_x86_64", + "macosx_11_0_x86_64", + "macosx_10_15_arm64", + "macosx_11_10_universal2", + # A real tag used by e.g. some numpy wheels + ( + "macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64." + "macosx_10_10_intel.macosx_10_10_x86_64" + ), + ], +) +def test_is_valid_pypi_wheel_return_true_for_supported_wheel(plat): + filename = f"foo-1.2.3-cp34-none-{plat}.whl" + assert not validate_wheel_filename_for_pypi(filename) + + +@pytest.mark.parametrize( + "plat", + [ + "linux_x86_64", + "linux_x86_64.win32", + "macosx_9_2_x86_64", + "macosx_12_2_arm64", + "macosx_10_15_amd64", + ], +) +def test_is_valid_pypi_wheel_raise_exception_for_aunsupported_wheel(plat): + filename = f"foo-1.2.3-cp34-none-{plat}.whl" + invalid = validate_wheel_filename_for_pypi(filename) + assert invalid diff --git a/etc/scripts/test_utils_pypi_supported_tags.py.ABOUT b/etc/scripts/test_utils_pypi_supported_tags.py.ABOUT new file mode 100644 index 0000000..176efac --- /dev/null +++ b/etc/scripts/test_utils_pypi_supported_tags.py.ABOUT @@ -0,0 +1,17 @@ +about_resource: test_utils_pypi_supported_tags.py + +type: github +namespace: pypa +name: warehouse +version: 37a83dd342d9e3b3ab4f6bde47ca30e6883e2c4d +subpath: tests/unit/forklift/test_legacy.py + +package_url: pkg:github/pypa/warehouse@37a83dd342d9e3b3ab4f6bde47ca30e6883e2c4d#tests/unit/forklift/test_legacy.py + +download_url: https://github.com/pypa/warehouse/blob/37a83dd342d9e3b3ab4f6bde47ca30e6883e2c4d/tests/unit/forklift/test_legacy.py +copyright: Copyright (c) The warehouse developers +homepage_url: https://warehouse.readthedocs.io +license_expression: apache-2.0 +notes: Test for wheel platform checking copied and heavily modified on + 2020-12-24 from warehouse. This contains the basic functions to check if a + wheel file name is would be supported for uploading to PyPI. diff --git a/etc/scripts/utils_dejacode.py b/etc/scripts/utils_dejacode.py new file mode 100644 index 0000000..bb37de1 --- /dev/null +++ b/etc/scripts/utils_dejacode.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# ScanCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# +import io +import os +import zipfile + +import requests +import saneyaml + +from packaging import version as packaging_version + +""" +Utility to create and retrieve package and ABOUT file data from DejaCode. +""" + +DEJACODE_API_KEY = os.environ.get('DEJACODE_API_KEY', '') +DEJACODE_API_URL = os.environ.get('DEJACODE_API_URL', '') + +DEJACODE_API_URL_PACKAGES = f'{DEJACODE_API_URL}packages/' +DEJACODE_API_HEADERS = { + 'Authorization': 'Token {}'.format(DEJACODE_API_KEY), + 'Accept': 'application/json; indent=4', +} + + +def can_do_api_calls(): + if not DEJACODE_API_KEY and DEJACODE_API_URL: + print('DejaCode DEJACODE_API_KEY and DEJACODE_API_URL not configured. Doing nothing') + return False + else: + return True + + +def fetch_dejacode_packages(params): + """ + Return a list of package data mappings calling the package API with using + `params` or an empty list. + """ + if not can_do_api_calls(): + return [] + + response = requests.get( + DEJACODE_API_URL_PACKAGES, + params=params, + headers=DEJACODE_API_HEADERS, + ) + + return response.json()['results'] + + +def get_package_data(distribution): + """ + Return a mapping of package data or None for a Distribution `distribution`. + """ + results = fetch_dejacode_packages(distribution.identifiers()) + + len_results = len(results) + + if len_results == 1: + return results[0] + + elif len_results > 1: + print(f'More than 1 entry exists, review at: {DEJACODE_API_URL_PACKAGES}') + else: + print('Could not find package:', distribution.download_url) + + +def update_with_dejacode_data(distribution): + """ + Update the Distribution `distribution` with DejaCode package data. Return + True if data was updated. + """ + package_data = get_package_data(distribution) + if package_data: + return distribution.update(package_data, keep_extra=False) + + print(f'No package found for: {distribution}') + + +def update_with_dejacode_about_data(distribution): + """ + Update the Distribution `distribution` wiht ABOUT code data fetched from + DejaCode. Return True if data was updated. + """ + package_data = get_package_data(distribution) + if package_data: + package_api_url = package_data['api_url'] + about_url = f'{package_api_url}about' + response = requests.get(about_url, headers=DEJACODE_API_HEADERS) + # note that this is YAML-formatted + about_text = response.json()['about_data'] + about_data = saneyaml.load(about_text) + + return distribution.update(about_data, keep_extra=True) + + print(f'No package found for: {distribution}') + + +def fetch_and_save_about_files(distribution, dest_dir='thirdparty'): + """ + Fetch and save in `dest_dir` the .ABOUT, .LICENSE and .NOTICE files fetched + from DejaCode for a Distribution `distribution`. Return True if files were + fetched. + """ + package_data = get_package_data(distribution) + if package_data: + package_api_url = package_data['api_url'] + about_url = f'{package_api_url}about_files' + response = requests.get(about_url, headers=DEJACODE_API_HEADERS) + about_zip = response.content + with io.BytesIO(about_zip) as zf: + with zipfile.ZipFile(zf) as zi: + zi.extractall(path=dest_dir) + return True + + print(f'No package found for: {distribution}') + + +def find_latest_dejacode_package(distribution): + """ + Return a mapping of package data for the closest version to + a Distribution `distribution` or None. + Return the newest of the packages if prefer_newest is True. + Filter out version-specific attributes. + """ + ids = distribution.purl_identifiers(skinny=True) + packages = fetch_dejacode_packages(params=ids) + if not packages: + return + + for package_data in packages: + matched = ( + package_data['download_url'] == distribution.download_url + and package_data['version'] == distribution.version + and package_data['filename'] == distribution.filename + ) + + if matched: + return package_data + + # there was no exact match, find the latest version + # TODO: consider the closest version rather than the latest + # or the version that has the best data + with_versions = [(packaging_version.parse(p['version']), p) for p in packages] + with_versions = sorted(with_versions) + latest_version, latest_package_version = sorted(with_versions)[-1] + print( + f'Found DejaCode latest version: {latest_version} ' + f'for dist: {distribution.package_url}', + ) + + return latest_package_version + + +def create_dejacode_package(distribution): + """ + Create a new DejaCode Package a Distribution `distribution`. + Return the new or existing package data. + """ + if not can_do_api_calls(): + return + + existing_package_data = get_package_data(distribution) + if existing_package_data: + return existing_package_data + + print(f'Creating new DejaCode package for: {distribution}') + + new_package_payload = { + # Trigger data collection, scan, and purl + 'collect_data': 1, + } + + fields_to_carry_over = [ + 'download_url' + 'type', + 'namespace', + 'name', + 'version', + 'qualifiers', + 'subpath', + 'license_expression', + 'copyright', + 'description', + 'homepage_url', + 'primary_language', + 'notice_text', + ] + + for field in fields_to_carry_over: + value = getattr(distribution, field, None) + if value: + new_package_payload[field] = value + + response = requests.post( + DEJACODE_API_URL_PACKAGES, + data=new_package_payload, + headers=DEJACODE_API_HEADERS, + ) + new_package_data = response.json() + if response.status_code != 201: + raise Exception(f'Error, cannot create package for: {distribution}') + + print(f'New Package created at: {new_package_data["absolute_url"]}') + return new_package_data diff --git a/etc/scripts/utils_pip_compatibility_tags.py b/etc/scripts/utils_pip_compatibility_tags.py new file mode 100644 index 0000000..4c6529b --- /dev/null +++ b/etc/scripts/utils_pip_compatibility_tags.py @@ -0,0 +1,192 @@ +"""Generate and work with PEP 425 Compatibility Tags. + +copied from pip-20.3.1 pip/_internal/utils/compatibility_tags.py +download_url: https://github.com/pypa/pip/blob/20.3.1/src/pip/_internal/utils/compatibility_tags.py + +Copyright (c) 2008-2020 The pip developers (see AUTHORS.txt file) + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +""" + +import re + +from packaging.tags import ( + compatible_tags, + cpython_tags, + generic_tags, + interpreter_name, + interpreter_version, + mac_platforms, +) + +_osx_arch_pat = re.compile(r'(.+)_(\d+)_(\d+)_(.+)') + + +def version_info_to_nodot(version_info): + # type: (Tuple[int, ...]) -> str + # Only use up to the first two numbers. + return ''.join(map(str, version_info[:2])) + + +def _mac_platforms(arch): + # type: (str) -> List[str] + match = _osx_arch_pat.match(arch) + if match: + name, major, minor, actual_arch = match.groups() + mac_version = (int(major), int(minor)) + arches = [ + # Since we have always only checked that the platform starts + # with "macosx", for backwards-compatibility we extract the + # actual prefix provided by the user in case they provided + # something like "macosxcustom_". It may be good to remove + # this as undocumented or deprecate it in the future. + '{}_{}'.format(name, arch[len('macosx_'):]) + for arch in mac_platforms(mac_version, actual_arch) + ] + else: + # arch pattern didn't match (?!) + arches = [arch] + return arches + + +def _custom_manylinux_platforms(arch): + # type: (str) -> List[str] + arches = [arch] + arch_prefix, arch_sep, arch_suffix = arch.partition('_') + if arch_prefix == 'manylinux2014': + # manylinux1/manylinux2010 wheels run on most manylinux2014 systems + # with the exception of wheels depending on ncurses. PEP 599 states + # manylinux1/manylinux2010 wheels should be considered + # manylinux2014 wheels: + # https://www.python.org/dev/peps/pep-0599/#backwards-compatibility-with-manylinux2010-wheels + if arch_suffix in {'i686', 'x86_64'}: + arches.append('manylinux2010' + arch_sep + arch_suffix) + arches.append('manylinux1' + arch_sep + arch_suffix) + elif arch_prefix == 'manylinux2010': + # manylinux1 wheels run on most manylinux2010 systems with the + # exception of wheels depending on ncurses. PEP 571 states + # manylinux1 wheels should be considered manylinux2010 wheels: + # https://www.python.org/dev/peps/pep-0571/#backwards-compatibility-with-manylinux1-wheels + arches.append('manylinux1' + arch_sep + arch_suffix) + return arches + + +def _get_custom_platforms(arch): + # type: (str) -> List[str] + arch_prefix, _arch_sep, _arch_suffix = arch.partition('_') + if arch.startswith('macosx'): + arches = _mac_platforms(arch) + elif arch_prefix in ['manylinux2014', 'manylinux2010']: + arches = _custom_manylinux_platforms(arch) + else: + arches = [arch] + return arches + + +def _expand_allowed_platforms(platforms): + # type: (Optional[List[str]]) -> Optional[List[str]] + if not platforms: + return None + + seen = set() + result = [] + + for p in platforms: + if p in seen: + continue + additions = [c for c in _get_custom_platforms(p) if c not in seen] + seen.update(additions) + result.extend(additions) + + return result + + +def _get_python_version(version): + # type: (str) -> PythonVersion + if len(version) > 1: + return int(version[0]), int(version[1:]) + else: + return (int(version[0]),) + + +def _get_custom_interpreter(implementation=None, version=None): + # type: (Optional[str], Optional[str]) -> str + if implementation is None: + implementation = interpreter_name() + if version is None: + version = interpreter_version() + return "{}{}".format(implementation, version) + + +def get_supported( + version=None, # type: Optional[str] + platforms=None, # type: Optional[List[str]] + impl=None, # type: Optional[str] + abis=None # type: Optional[List[str]] +): + # type: (...) -> List[Tag] + """Return a list of supported tags for each version specified in + `versions`. + + :param version: a string version, of the form "33" or "32", + or None. The version will be assumed to support our ABI. + :param platforms: specify a list of platforms you want valid + tags for, or None. If None, use the local system platform. + :param impl: specify the exact implementation you want valid + tags for, or None. If None, use the local interpreter impl. + :param abis: specify a list of abis you want valid + tags for, or None. If None, use the local interpreter abi. + """ + supported = [] # type: List[Tag] + + python_version = None # type: Optional[PythonVersion] + if version is not None: + python_version = _get_python_version(version) + + interpreter = _get_custom_interpreter(impl, version) + + platforms = _expand_allowed_platforms(platforms) + + is_cpython = (impl or interpreter_name()) == "cp" + if is_cpython: + supported.extend( + cpython_tags( + python_version=python_version, + abis=abis, + platforms=platforms, + ) + ) + else: + supported.extend( + generic_tags( + interpreter=interpreter, + abis=abis, + platforms=platforms, + ) + ) + supported.extend( + compatible_tags( + python_version=python_version, + interpreter=interpreter, + platforms=platforms, + ) + ) + + return supported diff --git a/etc/scripts/utils_pip_compatibility_tags.py.ABOUT b/etc/scripts/utils_pip_compatibility_tags.py.ABOUT new file mode 100644 index 0000000..7bbb026 --- /dev/null +++ b/etc/scripts/utils_pip_compatibility_tags.py.ABOUT @@ -0,0 +1,14 @@ +about_resource: utils_pip_compatibility_tags.py + +type: github +namespace: pypa +name: pip +version: 20.3.1 +subpath: src/pip/_internal/utils/compatibility_tags.py + +package_url: pkg:github/pypa/pip@20.3.1#src/pip/_internal/utils/compatibility_tags.py + +download_url: https://github.com/pypa/pip/blob/20.3.1/src/pip/_internal/utils/compatibility_tags.py +copyright: Copyright (c) 2008-2020 The pip developers (see AUTHORS.txt file) +license_expression: mit +notes: subset copied from pip for tag handling \ No newline at end of file diff --git a/etc/scripts/utils_pypi_supported_tags.py b/etc/scripts/utils_pypi_supported_tags.py new file mode 100644 index 0000000..8dcb70f --- /dev/null +++ b/etc/scripts/utils_pypi_supported_tags.py @@ -0,0 +1,109 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re + +""" +Wheel platform checking + +Copied and modified on 2020-12-24 from +https://github.com/pypa/warehouse/blob/37a83dd342d9e3b3ab4f6bde47ca30e6883e2c4d/warehouse/forklift/legacy.py + +This contains the basic functions to check if a wheel file name is would be +supported for uploading to PyPI. +""" + +# These platforms can be handled by a simple static list: +_allowed_platforms = { + "any", + "win32", + "win_amd64", + "win_ia64", + "manylinux1_x86_64", + "manylinux1_i686", + "manylinux2010_x86_64", + "manylinux2010_i686", + "manylinux2014_x86_64", + "manylinux2014_i686", + "manylinux2014_aarch64", + "manylinux2014_armv7l", + "manylinux2014_ppc64", + "manylinux2014_ppc64le", + "manylinux2014_s390x", + "linux_armv6l", + "linux_armv7l", +} +# macosx is a little more complicated: +_macosx_platform_re = re.compile(r"macosx_(?P\d+)_(\d+)_(?P.*)") +_macosx_arches = { + "ppc", + "ppc64", + "i386", + "x86_64", + "arm64", + "intel", + "fat", + "fat32", + "fat64", + "universal", + "universal2", +} +_macosx_major_versions = { + "10", + "11", +} + +# manylinux pep600 is a little more complicated: +_manylinux_platform_re = re.compile(r"manylinux_(\d+)_(\d+)_(?P.*)") +_manylinux_arches = { + "x86_64", + "i686", + "aarch64", + "armv7l", + "ppc64", + "ppc64le", + "s390x", +} + + +def is_supported_platform_tag(platform_tag): + """ + Return True if the ``platform_tag`` is supported on PyPI. + """ + if platform_tag in _allowed_platforms: + return True + m = _macosx_platform_re.match(platform_tag) + if ( + m + and m.group("major") in _macosx_major_versions + and m.group("arch") in _macosx_arches + ): + return True + m = _manylinux_platform_re.match(platform_tag) + if m and m.group("arch") in _manylinux_arches: + return True + return False + + +def validate_platforms_for_pypi(platforms): + """ + Validate if the wheel platforms are supported platform tags on Pypi. Return + a list of unsupported platform tags or an empty list if all tags are + supported. + """ + + # Check that if it's a binary wheel, it's on a supported platform + invalid_tags = [] + for plat in platforms: + if not is_supported_platform_tag(plat): + invalid_tags.append(plat) + return invalid_tags diff --git a/etc/scripts/utils_pypi_supported_tags.py.ABOUT b/etc/scripts/utils_pypi_supported_tags.py.ABOUT new file mode 100644 index 0000000..228a538 --- /dev/null +++ b/etc/scripts/utils_pypi_supported_tags.py.ABOUT @@ -0,0 +1,17 @@ +about_resource: utils_pypi_supported_tags.py + +type: github +namespace: pypa +name: warehouse +version: 37a83dd342d9e3b3ab4f6bde47ca30e6883e2c4d +subpath: warehouse/forklift/legacy.py + +package_url: pkg:github/pypa/warehouse@37a83dd342d9e3b3ab4f6bde47ca30e6883e2c4d#warehouse/forklift/legacy.py + +download_url: https://github.com/pypa/warehouse/blob/37a83dd342d9e3b3ab4f6bde47ca30e6883e2c4d/warehouse/forklift/legacy.py +copyright: Copyright (c) The warehouse developers +homepage_url: https://warehouse.readthedocs.io +license_expression: apache-2.0 +notes: Wheel platform checking copied and heavily modified on 2020-12-24 from + warehouse. This contains the basic functions to check if a wheel file name is + would be supported for uploading to PyPI. diff --git a/etc/scripts/utils_requirements.py b/etc/scripts/utils_requirements.py new file mode 100644 index 0000000..8b088ad --- /dev/null +++ b/etc/scripts/utils_requirements.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# ScanCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# +import subprocess + +""" +Utilities to manage requirements files and call pip. +NOTE: this should use ONLY the standard library and not import anything else. +""" + + +def load_requirements(requirements_file='requirements.txt', force_pinned=True): + """ + Yield package (name, version) tuples for each requirement in a `requirement` + file. Every requirement versions must be pinned if `force_pinned` is True. + Otherwise un-pinned requirements are returned with a None version + """ + with open(requirements_file) as reqs: + req_lines = reqs.read().splitlines(False) + return get_required_name_versions(req_lines, force_pinned) + + +def get_required_name_versions(requirement_lines, force_pinned=True): + """ + Yield required (name, version) tuples given a`requirement_lines` iterable of + requirement text lines. Every requirement versions must be pinned if + `force_pinned` is True. Otherwise un-pinned requirements are returned with a + None version + """ + for req_line in requirement_lines: + req_line = req_line.strip() + if not req_line or req_line.startswith('#'): + continue + if '==' not in req_line and force_pinned: + raise Exception(f'Requirement version is not pinned: {req_line}') + name = req_line + version = None + else: + name, _, version = req_line.partition('==') + name = name.lower().strip() + version = version.lower().strip() + yield name, version + + +def parse_requires(requires): + """ + Return a list of requirement lines extracted from the `requires` text from + a setup.cfg *_requires section such as the "install_requires" section. + """ + requires = [c for c in requires.splitlines(False) if c] + if not requires: + return [] + + requires = [''.join(r.split()) for r in requires if r and r.strip()] + return sorted(requires) + + +def lock_requirements(requirements_file='requirements.txt', site_packages_dir=None): + """ + Freeze and lock current installed requirements and save this to the + `requirements_file` requirements file. + """ + with open(requirements_file, 'w') as fo: + fo.write(get_installed_reqs(site_packages_dir=site_packages_dir)) + + +def lock_dev_requirements( + dev_requirements_file='requirements-dev.txt', + main_requirements_file='requirements.txt', + site_packages_dir=None, +): + """ + Freeze and lock current installed development-only requirements and save + this to the `dev_requirements_file` requirements file. Development-only is + achieved by subtracting requirements from the `main_requirements_file` + requirements file from the current requirements using package names (and + ignoring versions). + """ + main_names = {n for n, _v in load_requirements(main_requirements_file)} + all_reqs = get_installed_reqs(site_packages_dir=site_packages_dir) + all_req_lines = all_reqs.splitlines(False) + all_req_nvs = get_required_name_versions(all_req_lines) + dev_only_req_nvs = {n: v for n, v in all_req_nvs if n not in main_names} + + new_reqs = '\n'.join(f'{n}=={v}' for n, v in sorted(dev_only_req_nvs.items())) + with open(dev_requirements_file, 'w') as fo: + fo.write(new_reqs) + + +def get_installed_reqs(site_packages_dir): + """ + Return the installed pip requirements as text found in `site_packages_dir` as a text. + """ + # Also include these packages in the output with --all: wheel, distribute, setuptools, pip + args = ['pip', 'freeze', '--exclude-editable', '--all', '--path', site_packages_dir] + return subprocess.check_output(args, encoding='utf-8') diff --git a/etc/scripts/utils_thirdparty.py b/etc/scripts/utils_thirdparty.py new file mode 100644 index 0000000..360f07a --- /dev/null +++ b/etc/scripts/utils_thirdparty.py @@ -0,0 +1,2940 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# ScanCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# +from collections import defaultdict +import email +import itertools +import operator +import os +import re +import shutil +import subprocess +import tarfile +import tempfile +import time +import urllib + +import attr +import license_expression +import packageurl +import utils_pip_compatibility_tags +import utils_pypi_supported_tags +import requests +import saneyaml + +from commoncode import fileutils +from commoncode.hash import multi_checksums +from packaging import tags as packaging_tags +from packaging import version as packaging_version +from utils_requirements import load_requirements + +""" +Utilities to manage Python thirparty libraries source, binaries and metadata in +local directories and remote repositories. + +- update pip requirement files from installed packages for prod. and dev. +- build and save wheels for all required packages +- also build variants for wheels with native code for all each supported + operating systems (Linux, macOS, Windows) and Python versions (3.x) + combinations using remote Ci jobs +- collect source distributions for all required packages +- keep in sync wheels, distributions, ABOUT and LICENSE files to a PyPI-like + repository (using GitHub) +- create, update and fetch ABOUT, NOTICE and LICENSE metadata for all distributions + + +Approach +-------- + +The processing is organized around these key objects: + +- A PyPiPackage represents a PyPI package with its name and version. It tracks + the downloadable Distribution objects for that version: + + - one Sdist source Distribution object + - a list of Wheel binary Distribution objects + +- A Distribution (either a Wheel or Sdist) is identified by and created from its + filename. It also has the metadata used to populate an .ABOUT file and + document origin and license. A Distribution can be fetched from Repository. + Metadata can be loaded from and dumped to ABOUT files and optionally from + DejaCode package data. + +- An Environment is a combination of a Python version and operating system. + A Wheel Distribution also has Python/OS tags is supports and these can be + supported in a given Environment. + +- Paths or URLs to "filenames" live in a Repository, either a plain + LinksRepository (an HTML page listing URLs or a local directory) or a + PypiRepository (a PyPI simple index where each package name has an HTML page + listing URLs to all distribution types and versions). + Repositories and Distributions are related through filenames. + + + The Wheel models code is partially derived from the mit-licensed pip and the + Distribution/Wheel/Sdist design has been heavily inspired by the packaging- + dists library https://github.com/uranusjr/packaging-dists by Tzu-ping Chung +""" + +TRACE = False + +# Supported environments +PYTHON_VERSIONS = '36', '37', '38', '39', + +ABIS_BY_PYTHON_VERSION = { + '36':['cp36', 'cp36m'], + '37':['cp37', 'cp37m'], + '38':['cp38', 'cp38m'], + '39':['cp39', 'cp39m'], +} + +PLATFORMS_BY_OS = { + 'linux': [ + 'linux_x86_64', + 'manylinux1_x86_64', + 'manylinux2014_x86_64', + 'manylinux2010_x86_64', + ], + 'macos': [ + 'macosx_10_6_intel', 'macosx_10_6_x86_64', + 'macosx_10_9_intel', 'macosx_10_9_x86_64', + 'macosx_10_10_intel', 'macosx_10_10_x86_64', + 'macosx_10_11_intel', 'macosx_10_11_x86_64', + 'macosx_10_12_intel', 'macosx_10_12_x86_64', + 'macosx_10_13_intel', 'macosx_10_13_x86_64', + 'macosx_10_14_intel', 'macosx_10_14_x86_64', + 'macosx_10_15_intel', 'macosx_10_15_x86_64', + ], + 'windows': [ + 'win_amd64', + ], +} + +THIRDPARTY_DIR = 'thirdparty' +CACHE_THIRDPARTY_DIR = '.cache/thirdparty' + +REMOTE_LINKS_URL = 'https://thirdparty.aboutcode.org/pypi' + +EXTENSIONS_APP = '.pyz', +EXTENSIONS_SDIST = '.tar.gz', '.tar.bz2', '.zip', '.tar.xz', +EXTENSIONS_INSTALLABLE = EXTENSIONS_SDIST + ('.whl',) +EXTENSIONS_ABOUT = '.ABOUT', '.LICENSE', '.NOTICE', +EXTENSIONS = EXTENSIONS_INSTALLABLE + EXTENSIONS_ABOUT + EXTENSIONS_APP + +PYPI_SIMPLE_URL = 'https://pypi.org/simple' + +LICENSEDB_API_URL = 'https://scancode-licensedb.aboutcode.org' + +LICENSING = license_expression.Licensing() + +################################################################################ +# +# Fetch remote wheels and sources locally +# +################################################################################ + + +def fetch_wheels( + environment=None, + requirements_file='requirements.txt', + allow_unpinned=False, + dest_dir=THIRDPARTY_DIR, + remote_links_url=REMOTE_LINKS_URL, +): + """ + Download all of the wheel of packages listed in the ``requirements_file`` + requirements file into ``dest_dir`` directory. + + Only get wheels for the ``environment`` Enviromnent constraints. If the + provided ``environment`` is None then the current Python interpreter + environment is used implicitly. + + Only accept pinned requirements (e.g. with a version) unless + ``allow_unpinned`` is True. + + Use exclusively direct downloads from a remote repo at URL + ``remote_links_url``. If ``remote_links_url`` is a path, use this as a + directory of links instead of a URL. + + Yield tuples of (PypiPackage, error) where is None on success. + """ + missed = [] + + if not allow_unpinned: + force_pinned = True + else: + force_pinned = False + + rrp = list(get_required_remote_packages( + requirements_file=requirements_file, + force_pinned=force_pinned, + remote_links_url=remote_links_url, + )) + + fetched_filenames = set() + for name, version, package in rrp: + if not package: + missed.append((name, version,)) + nv = f'{name}=={version}' if version else name + yield None, f'fetch_wheels: Missing package in remote repo: {nv}' + + else: + fetched_filename = package.fetch_wheel( + environment=environment, + fetched_filenames=fetched_filenames, + dest_dir=dest_dir, + ) + + if fetched_filename: + fetched_filenames.add(fetched_filename) + error = None + else: + if fetched_filename in fetched_filenames: + error = None + else: + error = f'Failed to fetch' + yield package, error + + if missed: + rr = get_remote_repo() + print() + print(f'===> fetch_wheels: Missed some packages') + for n, v in missed: + nv = f'{n}=={v}' if v else n + print(f'Missed package {nv} in remote repo, has only:') + for pv in rr.get_versions(n): + print(' ', pv) + + +def fetch_sources( + requirements_file='requirements.txt', + allow_unpinned=False, + dest_dir=THIRDPARTY_DIR, + remote_links_url=REMOTE_LINKS_URL, +): + """ + Download all of the dependent package sources listed in the + ``requirements_file`` requirements file into ``dest_dir`` destination + directory. + + Use direct downloads to achieve this (not pip download). Use exclusively the + packages found from a remote repo at URL ``remote_links_url``. If + ``remote_links_url`` is a path, use this as a directory of links instead of + a URL. + + Only accept pinned requirements (e.g. with a version) unless + ``allow_unpinned`` is True. + + Yield tuples of (PypiPackage, error message) for each package where error + message will empty on success. + """ + missed = [] + + if not allow_unpinned: + force_pinned = True + else: + force_pinned = False + + rrp = list(get_required_remote_packages( + requirements_file=requirements_file, + force_pinned=force_pinned, + remote_links_url=remote_links_url, + )) + + for name, version, package in rrp: + if not package: + missed.append((name, name,)) + nv = f'{name}=={version}' if version else name + yield None, f'fetch_sources: Missing package in remote repo: {nv}' + + elif not package.sdist: + yield package, f'Missing sdist in links' + + else: + fetched = package.fetch_sdist(dest_dir=dest_dir) + error = f'Failed to fetch' if not fetched else None + yield package, error + +################################################################################ +# +# Core models +# +################################################################################ + + +@attr.attributes +class NameVer: + name = attr.ib( + type=str, + metadata=dict(help='Python package name, lowercase and normalized.'), + ) + + version = attr.ib( + type=str, + metadata=dict(help='Python package version string.'), + ) + + @property + def normalized_name(self): + return NameVer.normalize_name(self.name) + + @staticmethod + def normalize_name(name): + """ + Return a normalized package name per PEP503, and copied from + https://www.python.org/dev/peps/pep-0503/#id4 + """ + return name and re.sub(r"[-_.]+", "-", name).lower() or name + + @staticmethod + def standardize_name(name): + """ + Return a standardized package name, e.g. lowercased and using - not _ + """ + return name and re.sub(r"[-_]+", "-", name).lower() or name + + @property + def name_ver(self): + return f'{self.name}-{self.version}' + + def sortable_name_version(self): + """ + Return a tuple of values to sort by name, then version. + This method is a suitable to use as key for sorting NameVer instances. + """ + return self.normalized_name, packaging_version.parse(self.version) + + @classmethod + def sorted(cls, namevers): + return sorted(namevers, key=cls.sortable_name_version) + + +@attr.attributes +class Distribution(NameVer): + + # field names that can be updated from another dist of mapping + updatable_fields = [ + 'license_expression', + 'copyright', + 'description', + 'homepage_url', + 'primary_language', + 'notice_text', + 'extra_data', + ] + + filename = attr.ib( + repr=False, + type=str, + default='', + metadata=dict(help='File name.'), + ) + + path_or_url = attr.ib( + repr=False, + type=str, + default='', + metadata=dict(help='Path or download URL.'), + ) + + sha256 = attr.ib( + repr=False, + type=str, + default='', + metadata=dict(help='SHA256 checksum.'), + ) + + sha1 = attr.ib( + repr=False, + type=str, + default='', + metadata=dict(help='SHA1 checksum.'), + ) + + md5 = attr.ib( + repr=False, + type=int, + default=0, + metadata=dict(help='MD5 checksum.'), + ) + + type = attr.ib( + repr=False, + type=str, + default='pypi', + metadata=dict(help='Package type'), + ) + + namespace = attr.ib( + repr=False, + type=str, + default='', + metadata=dict(help='Package URL namespace'), + ) + + qualifiers = attr.ib( + repr=False, + type=dict, + default=attr.Factory(dict), + metadata=dict(help='Package URL qualifiers'), + ) + + subpath = attr.ib( + repr=False, + type=str, + default='', + metadata=dict(help='Package URL subpath'), + ) + + size = attr.ib( + repr=False, + type=str, + default='', + metadata=dict(help='Size in bytes.'), + ) + + primary_language = attr.ib( + repr=False, + type=str, + default='Python', + metadata=dict(help='Primary Programming language.'), + ) + + description = attr.ib( + repr=False, + type=str, + default='', + metadata=dict(help='Description.'), + ) + + homepage_url = attr.ib( + repr=False, + type=str, + default='', + metadata=dict(help='Homepage URL'), + ) + + notes = attr.ib( + repr=False, + type=str, + default='', + metadata=dict(help='Notes.'), + ) + + copyright = attr.ib( + repr=False, + type=str, + default='', + metadata=dict(help='Copyright.'), + ) + + license_expression = attr.ib( + repr=False, + type=str, + default='', + metadata=dict(help='License expression'), + ) + + licenses = attr.ib( + repr=False, + type=list, + default=attr.Factory(list), + metadata=dict(help='List of license mappings.'), + ) + + notice_text = attr.ib( + repr=False, + type=str, + default='', + metadata=dict(help='Notice text'), + ) + + extra_data = attr.ib( + repr=False, + type=dict, + default=attr.Factory(dict), + metadata=dict(help='Extra data'), + ) + + @property + def package_url(self): + """ + Return a Package URL string of self. + """ + return str(packageurl.PackageURL(**self.purl_identifiers())) + + @property + def download_url(self): + if self.path_or_url and self.path_or_url.startswith('https://'): + return self.path_or_url + else: + return self.get_best_download_url() + + @property + def about_filename(self): + return f'{self.filename}.ABOUT' + + def has_about_file(self, dest_dir=THIRDPARTY_DIR): + return os.path.exists(os.path.join(dest_dir, self.about_filename)) + + @property + def about_download_url(self): + return self.build_remote_download_url(self.about_filename) + + @property + def notice_filename(self): + return f'{self.filename}.NOTICE' + + @property + def notice_download_url(self): + return self.build_remote_download_url(self.notice_filename) + + @classmethod + def from_path_or_url(cls, path_or_url): + """ + Return a distribution built from the data found in the filename of a + `path_or_url` string. Raise an exception if this is not a valid + filename. + """ + filename = os.path.basename(path_or_url.strip('/')) + dist = cls.from_filename(filename) + dist.path_or_url = path_or_url + return dist + + @classmethod + def get_dist_class(cls, filename): + if filename.endswith('.whl'): + return Wheel + elif filename.endswith(('.zip', '.tar.gz',)): + return Sdist + raise InvalidDistributionFilename(filename) + + @classmethod + def from_filename(cls, filename): + """ + Return a distribution built from the data found in a `filename` string. + Raise an exception if this is not a valid filename + """ + clazz = cls.get_dist_class(filename) + return clazz.from_filename(filename) + + @classmethod + def from_data(cls, data, keep_extra=False): + """ + Return a distribution built from a `data` mapping. + """ + filename = data['filename'] + dist = cls.from_filename(filename) + dist.update(data, keep_extra=keep_extra) + return dist + + @classmethod + def from_dist(cls, data, dist): + """ + Return a distribution built from a `data` mapping and update it with data + from another dist Distribution. Return None if it cannot be created + """ + # We can only create from a dist of the same package + has_same_key_fields = all(data.get(kf) == getattr(dist, kf, None) + for kf in ('type', 'namespace', 'name') + ) + if not has_same_key_fields: + print(f'Missing key fields: Cannot derive a new dist from data: {data} and dist: {dist}') + return + + has_key_field_values = all(data.get(kf) for kf in ('type', 'name', 'version')) + if not has_key_field_values: + print(f'Missing key field values: Cannot derive a new dist from data: {data} and dist: {dist}') + return + + data = dict(data) + # do not overwrite the data with the other dist + # only supplement + data.update({k: v for k, v in dist.get_updatable_data().items() if not data.get(k)}) + return cls.from_data(data) + + @classmethod + def build_remote_download_url(cls, filename, base_url=REMOTE_LINKS_URL): + """ + Return a direct download URL for a file in our remote repo + """ + return f'{base_url}/{filename}' + + def get_best_download_url(self): + """ + Return the best download URL for this distribution where best means that + PyPI is better and our own remote repo URLs are second. + If none is found, return a synthetic remote URL. + """ + name = self.normalized_name + version = self.version + filename = self.filename + + pypi_package = get_pypi_package(name=name, version=version) + if pypi_package: + pypi_url = pypi_package.get_url_for_filename(filename) + if pypi_url: + return pypi_url + + remote_package = get_remote_package(name=name, version=version) + if remote_package: + remote_url = remote_package.get_url_for_filename(filename) + if remote_url: + return remote_url + else: + # the package may not have been published yet, so we craft a URL + # using our remote base URL + return self.build_remote_download_url(self.filename) + + def purl_identifiers(self, skinny=False): + """ + Return a mapping of non-empty identifier name/values for the purl + fields. If skinny is True, only inlucde type, namespace and name. + """ + identifiers = dict( + type=self.type, + namespace=self.namespace, + name=self.name, + ) + + if not skinny: + identifiers.update( + version=self.version, + subpath=self.subpath, + qualifiers=self.qualifiers, + ) + + return {k: v for k, v in sorted(identifiers.items()) if v} + + def identifiers(self, purl_as_fields=True): + """ + Return a mapping of non-empty identifier name/values. + Return each purl fields separately if purl_as_fields is True. + Otherwise return a package_url string for the purl. + """ + if purl_as_fields: + identifiers = self.purl_identifiers() + else: + identifiers = dict(package_url=self.package_url) + + identifiers.update( + download_url=self.download_url, + filename=self.filename, + md5=self.md5, + sha1=self.sha1, + package_url=self.package_url, + ) + + return {k: v for k, v in sorted(identifiers.items()) if v} + + def has_key_metadata(self): + """ + Return True if this distribution has key metadata required for basic attribution. + """ + if self.license_expression == 'public-domain': + # copyright not needed + return True + return self.license_expression and self.copyright and self.path_or_url + + def to_about(self): + """ + Return a mapping of ABOUT data from this distribution fields. + """ + about_data = dict( + about_resource=self.filename, + checksum_md5=self.md5, + checksum_sha1=self.sha1, + copyright=self.copyright, + description=self.description, + download_url=self.download_url, + homepage_url=self.homepage_url, + license_expression=self.license_expression, + name=self.name, + namespace=self.namespace, + notes=self.notes, + notice_file=self.notice_filename if self.notice_text else '', + package_url=self.package_url, + primary_language=self.primary_language, + qualifiers=self.qualifiers, + size=self.size, + subpath=self.subpath, + type=self.type, + version=self.version, + ) + + about_data.update(self.extra_data) + about_data = {k: v for k, v in sorted(about_data.items()) if v} + return about_data + + def to_dict(self): + """ + Return a mapping data from this distribution. + """ + return {k: v for k, v in attr.asdict(self).items() if v} + + def save_about_and_notice_files(self, dest_dir=THIRDPARTY_DIR): + """ + Save a .ABOUT file to `dest_dir`. Include a .NOTICE file if there is a + notice_text. + """ + + def save_if_modified(location, content): + if os.path.exists(location): + with open(location) as fi: + existing_content = fi.read() + if existing_content == content: + return False + + if TRACE: print(f'Saving ABOUT (and NOTICE) files for: {self}') + with open(location, 'w') as fo: + fo.write(content) + return True + + save_if_modified( + location=os.path.join(dest_dir, self.about_filename), + content=saneyaml.dump(self.to_about()), + ) + + notice_text = self.notice_text and self.notice_text.strip() + if notice_text: + save_if_modified( + location=os.path.join(dest_dir, self.notice_filename), + content=notice_text, + ) + + def load_about_data(self, about_filename_or_data=None, dest_dir=THIRDPARTY_DIR): + """ + Update self with ABOUT data loaded from an `about_filename_or_data` + which is either a .ABOUT file in `dest_dir` or an ABOUT data mapping. + `about_filename_or_data` defaults to this distribution default ABOUT + filename if not provided. Load the notice_text if present from dest_dir. + """ + if not about_filename_or_data: + about_filename_or_data = self.about_filename + + if isinstance(about_filename_or_data, str): + # that's an about_filename + about_path = os.path.join(dest_dir, about_filename_or_data) + if os.path.exists(about_path): + with open(about_path) as fi: + about_data = saneyaml.load(fi.read()) + else: + return False + else: + about_data = about_filename_or_data + + md5 = about_data.pop('checksum_md5', None) + if md5: + about_data['md5'] = md5 + sha1 = about_data.pop('checksum_sha1', None) + if sha1: + about_data['sha1'] = sha1 + sha256 = about_data.pop('checksum_sha256', None) + if sha256: + about_data['sha256'] = sha256 + + about_data.pop('about_resource', None) + notice_text = about_data.pop('notice_text', None) + notice_file = about_data.pop('notice_file', None) + if notice_text: + about_data['notice_text'] = notice_text + elif notice_file: + notice_loc = os.path.join(dest_dir, notice_file) + if os.path.exists(notice_loc): + with open(notice_loc) as fi: + about_data['notice_text'] = fi.read() + return self.update(about_data, keep_extra=True) + + def load_remote_about_data(self): + """ + Fetch and update self with "remote" data Distribution ABOUT file and + NOTICE file if any. Return True if the data was updated. + """ + try: + about_text = fetch_content_from_path_or_url_through_cache(self.about_download_url) + except RemoteNotFetchedException: + return False + + if not about_text: + return False + + about_data = saneyaml.load(about_text) + notice_file = about_data.pop('notice_file', None) + if notice_file: + try: + notice_text = fetch_content_from_path_or_url_through_cache(self.notice_download_url) + if notice_text: + about_data['notice_text'] = notice_text + except RemoteNotFetchedException: + print(f'Failed to fetch NOTICE file: {self.notice_download_url}') + return self.load_about_data(about_data) + + def get_checksums(self, dest_dir=THIRDPARTY_DIR): + """ + Return a mapping of computed checksums for this dist filename is + `dest_dir`. + """ + dist_loc = os.path.join(dest_dir, self.filename) + if os.path.exists(dist_loc): + return multi_checksums(dist_loc, checksum_names=('md5', 'sha1', 'sha256')) + else: + return {} + + def set_checksums(self, dest_dir=THIRDPARTY_DIR): + """ + Update self with checksums computed for this dist filename is `dest_dir`. + """ + self.update(self.get_checksums(dest_dir), overwrite=True) + + def validate_checksums(self, dest_dir=THIRDPARTY_DIR): + """ + Return True if all checksums that have a value in this dist match + checksums computed for this dist filename is `dest_dir`. + """ + real_checksums = self.get_checksums(dest_dir) + for csk in ('md5', 'sha1', 'sha256'): + csv = getattr(self, csk) + rcv = real_checksums.get(csk) + if csv and rcv and csv != rcv: + return False + return True + + def get_pip_hash(self): + """ + Return a pip hash option string as used in requirements for this dist. + """ + assert self.sha256, f'Missinh SHA256 for dist {self}' + return f'--hash=sha256:{self.sha256}' + + def get_license_keys(self): + return LICENSING.license_keys(self.license_expression, unique=True, simple=True) + + def fetch_license_files(self, dest_dir=THIRDPARTY_DIR): + """ + Fetch license files is missing in `dest_dir`. + Return True if license files were fetched. + """ + paths_or_urls = get_remote_repo().links + errors = [] + extra_lic_names = [l.get('file') for l in self.extra_data.get('licenses', {})] + extra_lic_names += [self.extra_data.get('license_file')] + extra_lic_names = [ln for ln in extra_lic_names if ln] + lic_names = [ f'{key}.LICENSE' for key in self.get_license_keys()] + for filename in lic_names + extra_lic_names: + floc = os.path.join(dest_dir, filename) + if os.path.exists(floc): + continue + + try: + # try remotely first + lic_url = get_link_for_filename( + filename=filename, paths_or_urls=paths_or_urls) + + fetch_and_save_path_or_url( + filename=filename, + dest_dir=dest_dir, + path_or_url=lic_url, + as_text=True, + ) + if TRACE: print(f'Fetched license from remote: {lic_url}') + + except: + try: + # try licensedb second + lic_url = f'{LICENSEDB_API_URL}/{filename}' + fetch_and_save_path_or_url( + filename=filename, + dest_dir=dest_dir, + path_or_url=lic_url, + as_text=True, + ) + if TRACE: print(f'Fetched license from licensedb: {lic_url}') + + except: + msg = f'No text for license {filename} in expression "{self.license_expression}" from {self}' + print(msg) + errors.append(msg) + + return errors + + def extract_pkginfo(self, dest_dir=THIRDPARTY_DIR): + """ + Return the text of the first PKG-INFO or METADATA file found in the + archive of this Distribution in `dest_dir`. Return None if not found. + """ + fmt = 'zip' if self.filename.endswith('.whl') else None + dist = os.path.join(dest_dir, self.filename) + with tempfile.TemporaryDirectory(prefix='pypi-tmp-extract') as td: + shutil.unpack_archive(filename=dist, extract_dir=td, format=fmt) + # NOTE: we only care about the first one found in the dist + # which may not be 100% right + for pi in fileutils.resource_iter(location=td, with_dirs=False): + if pi.endswith(('PKG-INFO', 'METADATA',)): + with open(pi) as fi: + return fi.read() + + def load_pkginfo_data(self, dest_dir=THIRDPARTY_DIR): + """ + Update self with data loaded from the PKG-INFO file found in the + archive of this Distribution in `dest_dir`. + """ + pkginfo_text = self.extract_pkginfo(dest_dir=dest_dir) + if not pkginfo_text: + print(f'!!!!PKG-INFO not found in {self.filename}') + return + raw_data = email.message_from_string(pkginfo_text) + + classifiers = raw_data.get_all('Classifier') or [] + + declared_license = [raw_data['License']] + [c for c in classifiers if c.startswith('License')] + other_classifiers = [c for c in classifiers if not c.startswith('License')] + + pkginfo_data = dict( + name=raw_data['Name'], + declared_license=declared_license, + version=raw_data['Version'], + description=raw_data['Summary'], + homepage_url=raw_data['Home-page'], + holder=raw_data['Author'], + holder_contact=raw_data['Author-email'], + keywords=raw_data['Keywords'], + classifiers=other_classifiers, + ) + + return self.update(pkginfo_data, keep_extra=True) + + def update_from_other_dist(self, dist): + """ + Update self using data from another dist + """ + return self.update(dist.get_updatable_data()) + + def get_updatable_data(self, data=None): + data = data or self.to_dict() + return { + k: v for k, v in data.items() + if v and k in self.updatable_fields + } + + def update(self, data, overwrite=False, keep_extra=True): + """ + Update self with a mapping of `data`. Keep unknown data as extra_data if + `keep_extra` is True. If `overwrite` is True, overwrite self with `data` + Return True if any data was updated, False otherwise. Raise an exception + if there are key data conflicts. + """ + package_url = data.get('package_url') + if package_url: + purl_from_data = packageurl.PackageURL.from_string(package_url) + purl_from_self = packageurl.PackageURL.from_string(self.package_url) + if purl_from_data != purl_from_self: + print( + f'Invalid dist update attempt, no same same purl with dist: ' + f'{self} using data {data}.') + return + + data.pop('about_resource', None) + dl = data.pop('download_url', None) + if dl: + data['path_or_url'] = dl + + updated = False + extra = {} + for k, v in data.items(): + if isinstance(v, str): + v = v.strip() + if not v: + continue + + if hasattr(self, k): + value = getattr(self, k, None) + if not value or (overwrite and value != v): + try: + setattr(self, k, v) + except Exception as e: + raise Exception(f'{self}, {k}, {v}') from e + updated = True + + elif keep_extra: + # note that we always overwrite extra + extra[k] = v + updated = True + + self.extra_data.update(extra) + + return updated + + +class InvalidDistributionFilename(Exception): + pass + + +@attr.attributes +class Sdist(Distribution): + + extension = attr.ib( + repr=False, + type=str, + default='', + metadata=dict(help='File extension, including leading dot.'), + ) + + @classmethod + def from_filename(cls, filename): + """ + Return a Sdist object built from a filename. + Raise an exception if this is not a valid sdist filename + """ + name_ver = None + extension = None + + for ext in EXTENSIONS_SDIST: + if filename.endswith(ext): + name_ver, extension, _ = filename.rpartition(ext) + break + + if not extension or not name_ver: + raise InvalidDistributionFilename(filename) + + name, _, version = name_ver.rpartition('-') + + if not name or not version: + raise InvalidDistributionFilename(filename) + + return cls( + type='pypi', + name=name, + version=version, + extension=extension, + filename=filename, + ) + + def to_filename(self): + """ + Return an sdist filename reconstructed from its fields (that may not be + the same as the original filename.) + """ + return f'{self.name}-{self.version}.{self.extension}' + + +@attr.attributes +class Wheel(Distribution): + + """ + Represents a wheel file. + + Copied and heavily modified from pip-20.3.1 copied from pip-20.3.1 + pip/_internal/models/wheel.py + + name: pip compatibility tags + version: 20.3.1 + download_url: https://github.com/pypa/pip/blob/20.3.1/src/pip/_internal/models/wheel.py + copyright: Copyright (c) 2008-2020 The pip developers (see AUTHORS.txt file) + license_expression: mit + notes: copied from pip-20.3.1 pip/_internal/models/wheel.py + + Copyright (c) 2008-2020 The pip developers (see AUTHORS.txt file) + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + """ + + get_wheel_from_filename = re.compile( + r"""^(?P(?P.+?)-(?P.*?)) + ((-(?P\d[^-]*?))?-(?P.+?)-(?P.+?)-(?P.+?) + \.whl)$""", + re.VERBOSE + ).match + + build = attr.ib( + type=str, + default='', + metadata=dict(help='Python wheel build.'), + ) + + python_versions = attr.ib( + type=list, + default=attr.Factory(list), + metadata=dict(help='List of wheel Python version tags.'), + ) + + abis = attr.ib( + type=list, + default=attr.Factory(list), + metadata=dict(help='List of wheel ABI tags.'), + ) + + platforms = attr.ib( + type=list, + default=attr.Factory(list), + metadata=dict(help='List of wheel platform tags.'), + ) + + tags = attr.ib( + repr=False, + type=set, + default=attr.Factory(set), + metadata=dict(help='Set of all tags for this wheel.'), + ) + + @classmethod + def from_filename(cls, filename): + """ + Return a wheel object built from a filename. + Raise an exception if this is not a valid wheel filename + """ + wheel_info = cls.get_wheel_from_filename(filename) + if not wheel_info: + raise InvalidDistributionFilename(filename) + + name = wheel_info.group('name').replace('_', '-') + # we'll assume "_" means "-" due to wheel naming scheme + # (https://github.com/pypa/pip/issues/1150) + version = wheel_info.group('ver').replace('_', '-') + build = wheel_info.group('build') + python_versions = wheel_info.group('pyvers').split('.') + abis = wheel_info.group('abis').split('.') + platforms = wheel_info.group('plats').split('.') + + # All the tag combinations from this file + tags = { + packaging_tags.Tag(x, y, z) for x in python_versions + for y in abis for z in platforms + } + + return cls( + filename=filename, + type='pypi', + name=name, + version=version, + build=build, + python_versions=python_versions, + abis=abis, + platforms=platforms, + tags=tags, + ) + + def is_supported_by_tags(self, tags): + """ + Return True is this wheel is compatible with one of a list of PEP 425 tags. + """ + return not self.tags.isdisjoint(tags) + + def is_supported_by_environment(self, environment): + """ + Return True if this wheel is compatible with the Environment + `environment`. + """ + return not self.is_supported_by_tags(environment.tags) + + def to_filename(self): + """ + Return a wheel filename reconstructed from its fields (that may not be + the same as the original filename.) + """ + build = f'-{self.build}' if self.build else '' + pyvers = '.'.join(self.python_versions) + abis = '.'.join(self.abis) + plats = '.'.join(self.platforms) + return f'{self.name}-{self.version}{build}-{pyvers}-{abis}-{plats}.whl' + + def is_pure(self): + """ + Return True if wheel `filename` is for a "pure" wheel e.g. a wheel that + runs on all Pythons 3 and all OSes. + + For example:: + + >>> Wheel.from_filename('aboutcode_toolkit-5.1.0-py2.py3-none-any.whl').is_pure() + True + >>> Wheel.from_filename('beautifulsoup4-4.7.1-py3-none-any.whl').is_pure() + True + >>> Wheel.from_filename('beautifulsoup4-4.7.1-py2-none-any.whl').is_pure() + False + >>> Wheel.from_filename('bitarray-0.8.1-cp36-cp36m-win_amd64.whl').is_pure() + False + >>> Wheel.from_filename('extractcode_7z-16.5-py2.py3-none-macosx_10_13_intel.whl').is_pure() + False + >>> Wheel.from_filename('future-0.16.0-cp36-none-any.whl').is_pure() + False + >>> Wheel.from_filename('foo-4.7.1-py3-none-macosx_10_13_intel.whl').is_pure() + False + >>> Wheel.from_filename('future-0.16.0-py3-cp36m-any.whl').is_pure() + False + """ + return ( + 'py3' in self.python_versions + and 'none' in self.abis + and 'any' in self.platforms + ) + + +def is_pure_wheel(filename): + try: + return Wheel.from_filename(filename).is_pure() + except: + return False + + +@attr.attributes +class PypiPackage(NameVer): + """ + A Python package with its "distributions", e.g. wheels and source + distribution , ABOUT files and licenses or notices. + """ + sdist = attr.ib( + repr=False, + type=str, + default='', + metadata=dict(help='Sdist source distribution for this package.'), + ) + + wheels = attr.ib( + repr=False, + type=list, + default=attr.Factory(list), + metadata=dict(help='List of Wheel for this package'), + ) + + @property + def specifier(self): + """ + A requirement specifier for this package + """ + if self.version: + return f'{self.name}=={self.version}' + else: + return self.name + + @property + def specifier_with_hashes(self): + """ + Return a requirement specifier for this package with --hash options for + all its distributions + """ + items = [self.specifier] + items += [d.get_pip_hashes() for d in self.get_distributions()] + return ' \\\n '.join(items) + + def get_supported_wheels(self, environment): + """ + Yield all the Wheel of this package supported and compatible with the + Environment `environment`. + """ + envt_tags = environment.tags() + for wheel in self.wheels: + if wheel.is_supported_by_tags(envt_tags): + yield wheel + + @classmethod + def package_from_dists(cls, dists): + """ + Return a new PypiPackage built from an iterable of Wheels and Sdist + objects all for the same package name and version. + + For example: + >>> w1 = Wheel(name='bitarray', version='0.8.1', build='', + ... python_versions=['cp36'], abis=['cp36m'], + ... platforms=['linux_x86_64']) + >>> w2 = Wheel(name='bitarray', version='0.8.1', build='', + ... python_versions=['cp36'], abis=['cp36m'], + ... platforms=['macosx_10_9_x86_64', 'macosx_10_10_x86_64']) + >>> sd = Sdist(name='bitarray', version='0.8.1') + >>> package = PypiPackage.package_from_dists(dists=[w1, w2, sd]) + >>> assert package.name == 'bitarray' + >>> assert package.version == '0.8.1' + >>> assert package.sdist == sd + >>> assert package.wheels == [w1, w2] + """ + dists = list(dists) + if not dists: + return + + reference_dist = dists[0] + normalized_name = reference_dist.normalized_name + version = reference_dist.version + + package = PypiPackage(name=normalized_name, version=version) + + for dist in dists: + if dist.normalized_name != normalized_name or dist.version != version: + if TRACE: + print( + f' Skipping inconsistent dist name and version: {dist} ' + f'Expected instead package name: {normalized_name} and version: "{version}"' + ) + continue + + if isinstance(dist, Sdist): + package.sdist = dist + + elif isinstance(dist, Wheel): + package.wheels.append(dist) + + else: + raise Exception(f'Unknown distribution type: {dist}') + + return package + + @classmethod + def packages_from_one_path_or_url(cls, path_or_url): + """ + Yield PypiPackages built from files found in at directory path or the + URL to an HTML page (that will be fetched). + """ + extracted_paths_or_urls = get_paths_or_urls(path_or_url) + return cls.packages_from_many_paths_or_urls(extracted_paths_or_urls) + + @classmethod + def packages_from_many_paths_or_urls(cls, paths_or_urls): + """ + Yield PypiPackages built from a list of paths or URLs. + """ + dists = cls.get_dists(paths_or_urls) + dists = NameVer.sorted(dists) + + for _projver, dists_of_package in itertools.groupby( + dists, key=NameVer.sortable_name_version, + ): + yield PypiPackage.package_from_dists(dists_of_package) + + @classmethod + def get_versions_from_path_or_url(cls, name, path_or_url): + """ + Return a subset list from a list of PypiPackages version at `path_or_url` + that match PypiPackage `name`. + """ + packages = cls.packages_from_one_path_or_url(path_or_url) + return cls.get_versions(name, packages) + + @classmethod + def get_versions(cls, name, packages): + """ + Return a subset list of package versions from a list of `packages` that + match PypiPackage `name`. + The list is sorted by version from oldest to most recent. + """ + norm_name = NameVer.normalize_name(name) + versions = [p for p in packages if p.normalized_name == norm_name] + return cls.sorted(versions) + + @classmethod + def get_latest_version(cls, name, packages): + """ + Return the latest version of PypiPackage `name` from a list of `packages`. + """ + versions = cls.get_versions(name, packages) + if not versions: + return + return versions[-1] + + @classmethod + def get_outdated_versions(cls, name, packages): + """ + Return all versions except the latest version of PypiPackage `name` from a + list of `packages`. + """ + versions = cls.get_versions(name, packages) + return versions[:-1] + + @classmethod + def get_name_version(cls, name, version, packages): + """ + Return the PypiPackage with `name` and `version` from a list of `packages` + or None if it is not found. + If `version` is None, return the latest version found. + """ + if version is None: + return cls.get_latest_version(name, packages) + + nvs = [p for p in cls.get_versions(name, packages) if p.version == version] + + if not nvs: + return + + if len(nvs) == 1: + return nvs[0] + + raise Exception(f'More than one PypiPackage with {name}=={version}') + + def fetch_wheel( + self, + environment=None, + fetched_filenames=None, + dest_dir=THIRDPARTY_DIR, + ): + """ + Download a binary wheel of this package matching the ``environment`` + Enviromnent constraints into ``dest_dir`` directory. + + Return the wheel filename if it was fetched, None otherwise. + + If the provided ``environment`` is None then the current Python + interpreter environment is used implicitly. Do not refetch wheel if + their name is in a provided ``fetched_filenames`` set. + """ + fetched_wheel_filename = None + if fetched_filenames is not None: + fetched_filenames = fetched_filenames + else: + fetched_filenames = set() + + for wheel in self.get_supported_wheels(environment): + + if wheel.filename not in fetched_filenames: + fetch_and_save_path_or_url( + filename=wheel.filename, + path_or_url=wheel.path_or_url, + dest_dir=dest_dir, + as_text=False, + ) + fetched_filenames.add(wheel.filename) + fetched_wheel_filename = wheel.filename + + # TODO: what if there is more than one? + break + + return fetched_wheel_filename + + def fetch_sdist(self, dest_dir=THIRDPARTY_DIR): + """ + Download the source distribution into `dest_dir` directory. Return the + fetched filename if it was fetched, False otherwise. + """ + if self.sdist: + assert self.sdist.filename + if TRACE: print('Fetching source for package:', self.name, self.version) + fetch_and_save_path_or_url( + filename=self.sdist.filename, + dest_dir=dest_dir, + path_or_url=self.sdist.path_or_url, + as_text=False, + ) + if TRACE: print(' --> file:', self.sdist.filename) + return self.sdist.filename + else: + print(f'Missing sdist for: {self.name}=={self.version}') + return False + + def delete_files(self, dest_dir=THIRDPARTY_DIR): + """ + Delete all PypiPackage files from `dest_dir` including wheels, sdist and + their ABOUT files. Note that we do not delete licenses since they can be + shared by several packages: therefore this would be done elsewhere in a + function that is aware of all used licenses. + """ + for to_delete in self.wheels + [self.sdist]: + if not to_delete: + continue + tdfn = to_delete.filename + for deletable in [tdfn, f'{tdfn}.ABOUT', f'{tdfn}.NOTICE']: + target = os.path.join(dest_dir, deletable) + if os.path.exists(target): + print(f'Deleting outdated {target}') + fileutils.delete(target) + + @classmethod + def get_dists(cls, paths_or_urls): + """ + Return a list of Distribution given a list of + `paths_or_urls` to wheels or source distributions. + + Each Distribution receives two extra attributes: + - the path_or_url it was created from + - its filename + + For example: + >>> paths_or_urls =''' + ... /home/foo/bitarray-0.8.1-cp36-cp36m-linux_x86_64.whl + ... bitarray-0.8.1-cp36-cp36m-macosx_10_9_x86_64.macosx_10_10_x86_64.whl + ... bitarray-0.8.1-cp36-cp36m-win_amd64.whl + ... httsp://example.com/bar/bitarray-0.8.1.tar.gz + ... bitarray-0.8.1.tar.gz.ABOUT bit.LICENSE'''.split() + >>> result = list(PypiPackage.get_dists(paths_or_urls)) + >>> for r in results: + ... r.filename = '' + ... r.path_or_url = '' + >>> expected = [ + ... Wheel(name='bitarray', version='0.8.1', build='', + ... python_versions=['cp36'], abis=['cp36m'], + ... platforms=['linux_x86_64']), + ... Wheel(name='bitarray', version='0.8.1', build='', + ... python_versions=['cp36'], abis=['cp36m'], + ... platforms=['macosx_10_9_x86_64', 'macosx_10_10_x86_64']), + ... Wheel(name='bitarray', version='0.8.1', build='', + ... python_versions=['cp36'], abis=['cp36m'], + ... platforms=['win_amd64']), + ... Sdist(name='bitarray', version='0.8.1') + ... ] + >>> assert expected == result + """ + installable = [f for f in paths_or_urls if f.endswith(EXTENSIONS_INSTALLABLE)] + for path_or_url in installable: + try: + yield Distribution.from_path_or_url(path_or_url) + except InvalidDistributionFilename: + if TRACE: + print(f'Skipping invalid distribution from: {path_or_url}') + continue + + def get_distributions(self): + """ + Yield all distributions available for this PypiPackage + """ + if self.sdist: + yield self.sdist + for wheel in self.wheels: + yield wheel + + def get_url_for_filename(self, filename): + """ + Return the URL for this filename or None. + """ + for dist in self.get_distributions(): + if dist.filename == filename: + return dist.path_or_url + + +@attr.attributes +class Environment: + """ + An Environment describes a target installation environment with its + supported Python version, ABI, platform, implementation and related + attributes. We can use these to pass as `pip download` options and force + fetching only the subset of packages that match these Environment + constraints as opposed to the current running Python interpreter + constraints. + """ + + python_version = attr.ib( + type=str, + default='', + metadata=dict(help='Python version supported by this environment.'), + ) + + operating_system = attr.ib( + type=str, + default='', + metadata=dict(help='operating system supported by this environment.'), + ) + + implementation = attr.ib( + type=str, + default='cp', + metadata=dict(help='Python implementation supported by this environment.'), + ) + + abis = attr.ib( + type=list, + default=attr.Factory(list), + metadata=dict(help='List of ABI tags supported by this environment.'), + ) + + platforms = attr.ib( + type=list, + default=attr.Factory(list), + metadata=dict(help='List of platform tags supported by this environment.'), + ) + + @classmethod + def from_pyver_and_os(cls, python_version, operating_system): + if '.' in python_version: + python_version = ''.join(python_version.split('.')) + + return cls( + python_version=python_version, + implementation='cp', + abis=ABIS_BY_PYTHON_VERSION[python_version], + platforms=PLATFORMS_BY_OS[operating_system], + operating_system=operating_system, + ) + + def get_pip_cli_options(self): + """ + Return a list of pip command line options for this environment. + """ + options = [ + '--python-version', self.python_version, + '--implementation', self.implementation, + '--abi', self.abi, + ] + for platform in self.platforms: + options.extend(['--platform', platform]) + return options + + def tags(self): + """ + Return a set of all the PEP425 tags supported by this environment. + """ + return set(utils_pip_compatibility_tags.get_supported( + version=self.python_version or None, + impl=self.implementation or None, + platforms=self.platforms or None, + abis=self.abis or None, + )) + +################################################################################ +# +# PyPI repo and link index for package wheels and sources +# +################################################################################ + + +@attr.attributes +class Repository: + """ + A PyPI or links Repository of Python packages: wheels, sdist, ABOUT, etc. + """ + + packages_by_normalized_name = attr.ib( + type=dict, + default=attr.Factory(lambda: defaultdict(list)), + metadata=dict(help= + 'Mapping of {package name: [package objects]} available in this repo'), + ) + + packages_by_normalized_name_version = attr.ib( + type=dict, + default=attr.Factory(dict), + metadata=dict(help= + 'Mapping of {(name, version): package object} available in this repo'), + ) + + def get_links(self, *args, **kwargs): + raise NotImplementedError() + + def get_versions(self, name): + """ + Return a list of all available PypiPackage version for this package name. + The list may be empty. + """ + raise NotImplementedError() + + def get_package(self, name, version): + """ + Return the PypiPackage with name and version or None. + """ + raise NotImplementedError() + + def get_latest_version(self, name): + """ + Return the latest PypiPackage version for this package name or None. + """ + raise NotImplementedError() + + +@attr.attributes +class LinksRepository(Repository): + """ + Represents a simple links repository which is either a local directory with + Python wheels and sdist or a remote URL to an HTML with links to these. + (e.g. suitable for use with pip --find-links). + """ + path_or_url = attr.ib( + type=str, + default='', + metadata=dict(help='Package directory path or URL'), + ) + + links = attr.ib( + type=list, + default=attr.Factory(list), + metadata=dict(help='List of links available in this repo'), + ) + + def __attrs_post_init__(self): + if not self.links: + self.links = get_paths_or_urls(links_url=self.path_or_url) + if not self.packages_by_normalized_name: + for p in PypiPackage.packages_from_many_paths_or_urls(paths_or_urls=self.links): + normalized_name = p.normalized_name + self.packages_by_normalized_name[normalized_name].append(p) + self.packages_by_normalized_name_version[(normalized_name, p.version)] = p + + def get_links(self, *args, **kwargs): + return self.links or [] + + def get_versions(self, name): + name = name and NameVer.normalize_name(name) + return self.packages_by_normalized_name.get(name, []) + + def get_latest_version(self, name): + return PypiPackage.get_latest_version(name, self.get_versions(name)) + + def get_package(self, name, version): + return PypiPackage.get_name_version(name, version, self.get_versions(name)) + + +@attr.attributes +class PypiRepository(Repository): + """ + Represents the public PyPI simple index. + It is populated lazily based on requested packages names + """ + simple_url = attr.ib( + type=str, + default=PYPI_SIMPLE_URL, + metadata=dict(help='Base PyPI simple URL for this index.'), + ) + + links_by_normalized_name = attr.ib( + type=dict, + default=attr.Factory(lambda: defaultdict(list)), + metadata=dict(help='Mapping of {package name: [links]} available in this repo'), + ) + + def _fetch_links(self, name): + name = name and NameVer.normalize_name(name) + return find_pypi_links(name=name, simple_url=self.simple_url) + + def _populate_links_and_packages(self, name): + name = name and NameVer.normalize_name(name) + if name in self.links_by_normalized_name: + return + + links = self._fetch_links(name) + self.links_by_normalized_name[name] = links + + packages = list(PypiPackage.packages_from_many_paths_or_urls(paths_or_urls=links)) + self.packages_by_normalized_name[name] = packages + + for p in packages: + name = name and NameVer.normalize_name(p.name) + self.packages_by_normalized_name_version[(name, p.version)] = p + + def get_links(self, name, *args, **kwargs): + name = name and NameVer.normalize_name(name) + self._populate_links_and_packages(name) + return self.links_by_normalized_name.get(name, []) + + def get_versions(self, name): + name = name and NameVer.normalize_name(name) + self._populate_links_and_packages(name) + return self.packages_by_normalized_name.get(name, []) + + def get_latest_version(self, name): + return PypiPackage.get_latest_version(name, self.get_versions(name)) + + def get_package(self, name, version): + return PypiPackage.get_name_version(name, version, self.get_versions(name)) + +################################################################################ +# Globals for remote repos to be lazily created and cached on first use for the +# life of the session together with some convenience functions. +################################################################################ + + +def get_local_packages(directory=THIRDPARTY_DIR): + """ + Return the list of all PypiPackage objects built from a local directory. Return + an empty list if the package cannot be found. + """ + return list(PypiPackage.packages_from_one_path_or_url(path_or_url=directory)) + + +def get_local_repo(directory=THIRDPARTY_DIR): + return LinksRepository(path_or_url=directory) + + +_REMOTE_REPO = None + + +def get_remote_repo(remote_links_url=REMOTE_LINKS_URL): + global _REMOTE_REPO + if not _REMOTE_REPO: + _REMOTE_REPO = LinksRepository(path_or_url=remote_links_url) + return _REMOTE_REPO + + +def get_remote_package(name, version, remote_links_url=REMOTE_LINKS_URL): + """ + Return a PypiPackage or None. + """ + try: + return get_remote_repo(remote_links_url).get_package(name, version) + except RemoteNotFetchedException as e: + print(f'Failed to fetch remote package info: {e}') + + +_PYPI_REPO = None + + +def get_pypi_repo(pypi_simple_url=PYPI_SIMPLE_URL): + global _PYPI_REPO + if not _PYPI_REPO: + _PYPI_REPO = PypiRepository(simple_url=pypi_simple_url) + return _PYPI_REPO + + +def get_pypi_package(name, version, pypi_simple_url=PYPI_SIMPLE_URL): + """ + Return a PypiPackage or None. + """ + try: + return get_pypi_repo(pypi_simple_url).get_package(name, version) + except RemoteNotFetchedException as e: + print(f'Failed to fetch remote package info: {e}') + +################################################################################ +# +# Basic file and URL-based operations using a persistent file-based Cache +# +################################################################################ + + +@attr.attributes +class Cache: + """ + A simple file-based cache based only on a filename presence. + This is used to avoid impolite fetching from remote locations. + """ + + directory = attr.ib(type=str, default=CACHE_THIRDPARTY_DIR) + + def __attrs_post_init__(self): + os.makedirs(self.directory, exist_ok=True) + + def clear(self): + shutil.rmtree(self.directory) + + def get(self, path_or_url, as_text=True): + """ + Get a file from a `path_or_url` through the cache. + `path_or_url` can be a path or a URL to a file. + """ + filename = os.path.basename(path_or_url.strip('/')) + cached = os.path.join(self.directory, filename) + + if not os.path.exists(cached): + content = get_file_content(path_or_url=path_or_url, as_text=as_text) + wmode = 'w' if as_text else 'wb' + with open(cached, wmode) as fo: + fo.write(content) + return content + else: + return get_local_file_content(path=cached, as_text=as_text) + + def put(self, filename, content): + """ + Put in the cache the `content` of `filename`. + """ + cached = os.path.join(self.directory, filename) + wmode = 'wb' if isinstance(content, bytes) else 'w' + with open(cached, wmode) as fo: + fo.write(content) + + +def get_file_content(path_or_url, as_text=True): + """ + Fetch and return the content at `path_or_url` from either a local path or a + remote URL. Return the content as bytes is `as_text` is False. + """ + if (path_or_url.startswith('file://') + or (path_or_url.startswith('/') and os.path.exists(path_or_url)) + ): + return get_local_file_content(path=path_or_url, as_text=as_text) + + elif path_or_url.startswith('https://'): + if TRACE: print(f'Fetching: {path_or_url}') + _headers, content = get_remote_file_content(url=path_or_url, as_text=as_text) + return content + + else: + raise Exception(f'Unsupported URL scheme: {path_or_url}') + + +def get_local_file_content(path, as_text=True): + """ + Return the content at `url` as text. Return the content as bytes is + `as_text` is False. + """ + if path.startswith('file://'): + path = path[7:] + + mode = 'r' if as_text else 'rb' + with open(path, mode) as fo: + return fo.read() + + +class RemoteNotFetchedException(Exception): + pass + + +def get_remote_file_content(url, as_text=True, headers_only=False, headers=None, _delay=0,): + """ + Fetch and return a tuple of (headers, content) at `url`. Return content as a + text string if `as_text` is True. Otherwise return the content as bytes. + + If `header_only` is True, return only (headers, None). Headers is a mapping + of HTTP headers. + Retries multiple times to fetch if there is a HTTP 429 throttling response + and this with an increasing delay. + """ + time.sleep(_delay) + headers = headers or {} + # using a GET with stream=True ensure we get the the final header from + # several redirects and that we can ignore content there. A HEAD request may + # not get us this last header + with requests.get(url, allow_redirects=True, stream=True, headers=headers) as response: + status = response.status_code + if status != requests.codes.ok: # NOQA + if status == 429 and _delay < 20: + # too many requests: start some exponential delay + increased_delay = (_delay * 2) or 1 + + return get_remote_file_content( + url, + as_text=as_text, + headers_only=headers_only, + _delay=increased_delay, + ) + + else: + raise RemoteNotFetchedException(f'Failed HTTP request from {url} with {status}') + + if headers_only: + return response.headers, None + + return response.headers, response.text if as_text else response.content + + +def get_url_content_if_modified(url, md5, _delay=0,): + """ + Return fetched content bytes at `url` or None if the md5 has not changed. + Retries multiple times to fetch if there is a HTTP 429 throttling response + and this with an increasing delay. + """ + time.sleep(_delay) + headers = None + if md5: + etag = f'"{md5}"' + headers = {'If-None-Match': f'{etag}'} + + # using a GET with stream=True ensure we get the the final header from + # several redirects and that we can ignore content there. A HEAD request may + # not get us this last header + with requests.get(url, allow_redirects=True, stream=True, headers=headers) as response: + status = response.status_code + if status == requests.codes.too_many_requests and _delay < 20: # NOQA + # too many requests: start waiting with some exponential delay + _delay = (_delay * 2) or 1 + return get_url_content_if_modified(url=url, md5=md5, _delay=_delay) + + elif status == requests.codes.not_modified: # NOQA + # all is well, the md5 is the same + return None + + elif status != requests.codes.ok: # NOQA + raise RemoteNotFetchedException(f'Failed HTTP request from {url} with {status}') + + return response.content + + +def get_remote_headers(url): + """ + Fetch and return a mapping of HTTP headers of `url`. + """ + headers, _content = get_remote_file_content(url, headers_only=True) + return headers + + +def fetch_and_save_filename_from_paths_or_urls( + filename, + paths_or_urls, + dest_dir=THIRDPARTY_DIR, + as_text=True, +): + """ + Return the content from fetching the `filename` file name found in the + `paths_or_urls` list of URLs or paths and save to `dest_dir`. Raise an + Exception on errors. Treats the content as text if `as_text` is True + otherwise as binary. + """ + path_or_url = get_link_for_filename( + filename=filename, + paths_or_urls=paths_or_urls, + ) + + return fetch_and_save_path_or_url( + filename=filename, + dest_dir=dest_dir, + path_or_url=path_or_url, + as_text=as_text, + ) + + +def fetch_content_from_path_or_url_through_cache(path_or_url, as_text=True, cache=Cache()): + """ + Return the content from fetching at path or URL. Raise an Exception on + errors. Treats the content as text if as_text is True otherwise as treat as + binary. Use the provided file cache. This is the main entry for using the + cache. + + Note: the `cache` argument is a global, though it does not really matter + since it does not hold any state which is only kept on disk. + """ + if cache: + return cache.get(path_or_url=path_or_url, as_text=as_text) + else: + return get_file_content(path_or_url=path_or_url, as_text=as_text) + + +def fetch_and_save_path_or_url(filename, dest_dir, path_or_url, as_text=True, through_cache=True): + """ + Return the content from fetching the `filename` file name at URL or path + and save to `dest_dir`. Raise an Exception on errors. Treats the content as + text if as_text is True otherwise as treat as binary. + """ + if through_cache: + content = fetch_content_from_path_or_url_through_cache(path_or_url, as_text) + else: + content = fetch_content_from_path_or_url_through_cache(path_or_url, as_text, cache=None) + + output = os.path.join(dest_dir, filename) + wmode = 'w' if as_text else 'wb' + with open(output, wmode) as fo: + fo.write(content) + return content + +################################################################################ +# +# Sync and fix local thirdparty directory for various issues and gaps +# +################################################################################ + + +def fetch_missing_sources(dest_dir=THIRDPARTY_DIR): + """ + Given a thirdparty dir, fetch missing source distributions from our remote + repo or PyPI. Return a list of (name, version) tuples for source + distribution that were not found + """ + not_found = [] + local_packages = get_local_packages(directory=dest_dir) + remote_repo = get_remote_repo() + pypi_repo = get_pypi_repo() + + for package in local_packages: + if not package.sdist: + print(f'Finding sources for: {package.name}=={package.version}: ', end='') + try: + pypi_package = pypi_repo.get_package( + name=package.name, version=package.version) + + if pypi_package and pypi_package.sdist: + print(f'Fetching sources from Pypi') + pypi_package.fetch_sdist(dest_dir=dest_dir) + continue + else: + remote_package = remote_repo.get_package( + name=package.name, version=package.version) + + if remote_package and remote_package.sdist: + print(f'Fetching sources from Remote') + remote_package.fetch_sdist(dest_dir=dest_dir) + continue + + except RemoteNotFetchedException as e: + print(f'Failed to fetch remote package info: {e}') + + print(f'No sources found') + not_found.append((package.name, package.version,)) + + return not_found + + +def fetch_missing_wheels( + python_versions=PYTHON_VERSIONS, + operating_systems=PLATFORMS_BY_OS, + dest_dir=THIRDPARTY_DIR, +): + """ + Given a thirdparty dir fetch missing wheels for all known combos of Python + versions and OS. Return a list of tuple (Package, Environment) for wheels + that were not found locally or remotely. + """ + local_packages = get_local_packages(directory=dest_dir) + evts = itertools.product(python_versions, operating_systems) + environments = [Environment.from_pyver_and_os(pyv, os) for pyv, os in evts] + packages_and_envts = itertools.product(local_packages, environments) + + not_fetched = [] + fetched_filenames = set() + for package, envt in packages_and_envts: + + filename = package.fetch_wheel( + environment=envt, + fetched_filenames=fetched_filenames, + dest_dir=dest_dir, + ) + + if filename: + fetched_filenames.add(filename) + else: + not_fetched.append((package, envt,)) + + return not_fetched + + +def build_missing_wheels( + packages_and_envts, + build_remotely=False, + with_deps=False, + dest_dir=THIRDPARTY_DIR, +): + """ + Build all wheels in a list of tuple (Package, Environment) and save in + `dest_dir`. Return a list of tuple (Package, Environment), and a list of + built wheel filenames. + """ + + not_built = [] + built_filenames = [] + + packages_and_envts = itertools.groupby( + sorted(packages_and_envts), key=operator.itemgetter(0)) + + for package, pkg_envts in packages_and_envts: + + envts = [envt for _pkg, envt in pkg_envts] + python_versions = sorted(set(e.python_version for e in envts)) + operating_systems = sorted(set(e.operating_system for e in envts)) + built = None + try: + built = build_wheels( + requirements_specifier=package.specifier, + with_deps=with_deps, + build_remotely=build_remotely, + python_versions=python_versions, + operating_systems=operating_systems, + verbose=False, + dest_dir=dest_dir, + ) + print('.') + except Exception as e: + import traceback + print('#############################################################') + print('############# WHEEL BUILD FAILED ######################') + traceback.print_exc() + print() + print('#############################################################') + + if not built: + for envt in pkg_envts: + not_built.append((package, envt)) + else: + for bfn in built: + print(f' --> Built wheel: {bfn}') + built_filenames.append(bfn) + + return not_built, built_filenames + +################################################################################ +# +# Functions to handle remote or local repo used to "find-links" +# +################################################################################ + + +def get_paths_or_urls(links_url): + if links_url.startswith('https:'): + paths_or_urls = find_links_from_release_url(links_url) + else: + paths_or_urls = find_links_from_dir(links_url) + return paths_or_urls + + +def find_links_from_dir(directory=THIRDPARTY_DIR): + """ + Return a list of path to files in `directory` for any file that ends with + any of the extension in the list of `extensions` strings. + """ + base = os.path.abspath(directory) + files = [os.path.join(base, f) for f in os.listdir(base) if f.endswith(EXTENSIONS)] + return files + + +get_links = re.compile('href="([^"]+)"').findall + + +def find_links_from_release_url(links_url=REMOTE_LINKS_URL): + """ + Return a list of download link URLs found in the HTML page at `links_url` + URL that starts with the `prefix` string and ends with any of the extension + in the list of `extensions` strings. Use the `base_url` to prefix the links. + """ + if TRACE: print(f'Finding links for {links_url}') + + plinks_url = urllib.parse.urlparse(links_url) + + base_url = urllib.parse.SplitResult( + plinks_url.scheme, plinks_url.netloc, '', '', '').geturl() + + if TRACE: print(f'Base URL {base_url}') + + _headers, text = get_remote_file_content(links_url) + links = [] + for link in get_links(text): + if not link.endswith(EXTENSIONS): + continue + + plink = urllib.parse.urlsplit(link) + + if plink.scheme: + # full URL kept as-is + url = link + + if plink.path.startswith('/'): + # absolute link + url = f'{base_url}{link}' + + else: + # relative link + url = f'{links_url}/{link}' + + if TRACE: print(f'Adding URL: {url}') + + links.append(url) + + if TRACE: print(f'Found {len(links)} links at {links_url}') + return links + + +def find_pypi_links(name, simple_url=PYPI_SIMPLE_URL): + """ + Return a list of download link URLs found in a PyPI simple index for package name. + with the list of `extensions` strings. Use the `simple_url` PyPI url. + """ + if TRACE: print(f'Finding links for {simple_url}') + + name = name and NameVer.normalize_name(name) + simple_url = simple_url.strip('/') + simple_url = f'{simple_url}/{name}' + + _headers, text = get_remote_file_content(simple_url) + links = get_links(text) + # TODO: keep sha256 + links = [l.partition('#sha256=') for l in links] + links = [url for url, _, _sha256 in links] + links = [l for l in links if l.endswith(EXTENSIONS)] + return links + + +def get_link_for_filename(filename, paths_or_urls): + """ + Return a link for `filename` found in the `links` list of URLs or paths. Raise an + exception if no link is found or if there are more than one link for that + file name. + """ + path_or_url = [l for l in paths_or_urls if l.endswith(f'/{filename}')] + if not path_or_url: + raise Exception(f'Missing link to file: {filename}') + if not len(path_or_url) == 1: + raise Exception(f'Multiple links to file: {filename}: \n' + '\n'.join(path_or_url)) + return path_or_url[0] + +################################################################################ +# +# Requirements processing +# +################################################################################ + + +class MissingRequirementException(Exception): + pass + + +def get_required_packages(required_name_versions): + """ + Return a tuple of (remote packages, PyPI packages) where each is a mapping + of {(name, version): PypiPackage} for packages listed in the + `required_name_versions` list of (name, version) tuples. Raise a + MissingRequirementException with a list of missing (name, version) if a + requirement cannot be satisfied remotely or in PyPI. + """ + remote_repo = get_remote_repo() + + remote_packages = {(name, version): remote_repo.get_package(name, version) + for name, version in required_name_versions} + + pypi_repo = get_pypi_repo() + pypi_packages = {(name, version): pypi_repo.get_package(name, version) + for name, version in required_name_versions} + + # remove any empty package (e.g. that do not exist in some place) + remote_packages = {nv: p for nv, p in remote_packages.items() if p} + pypi_packages = {nv: p for nv, p in pypi_packages.items() if p} + + # check that we are not missing any + repos_name_versions = set(remote_packages.keys()) | set(pypi_packages.keys()) + missing_name_versions = required_name_versions.difference(repos_name_versions) + if missing_name_versions: + raise MissingRequirementException(sorted(missing_name_versions)) + + return remote_packages, pypi_packages + + +def get_required_remote_packages( + requirements_file='requirements.txt', + force_pinned=True, + remote_links_url=REMOTE_LINKS_URL, +): + """ + Yield tuple of (name, version, PypiPackage) for packages listed in the + `requirements_file` requirements file and found in the PyPI-like link repo + ``remote_links_url`` if this is a URL. Treat this ``remote_links_url`` as a + local directory path to a wheels directory if this is not a a URL. + """ + required_name_versions = load_requirements( + requirements_file=requirements_file, + force_pinned=force_pinned, + ) + + if remote_links_url.startswith('https://'): + repo = get_remote_repo(remote_links_url=remote_links_url) + else: + # a local path + assert os.path.exists(remote_links_url) + repo = get_local_repo(directory=remote_links_url) + + for name, version in required_name_versions: + if version: + yield name, version, repo.get_package(name, version) + else: + yield name, version, repo.get_latest_version(name) + + +def update_requirements(name, version=None, requirements_file='requirements.txt'): + """ + Upgrade or add `package_name` with `new_version` to the `requirements_file` + requirements file. Write back requirements sorted with name and version + canonicalized. Note: this cannot deal with hashed or unpinned requirements. + Do nothing if the version already exists as pinned. + """ + normalized_name = NameVer.normalize_name(name) + + is_updated = False + updated_name_versions = [] + for existing_name, existing_version in load_requirements(requirements_file, force_pinned=False): + + existing_normalized_name = NameVer.normalize_name(existing_name) + + if normalized_name == existing_normalized_name: + if version != existing_version: + is_updated = True + updated_name_versions.append((existing_normalized_name, existing_version,)) + + if is_updated: + updated_name_versions = sorted(updated_name_versions) + nvs = '\n'.join(f'{name}=={version}' for name, version in updated_name_versions) + + with open(requirements_file, 'w') as fo: + fo.write(nvs) + + +def hash_requirements(dest_dir=THIRDPARTY_DIR, requirements_file='requirements.txt'): + """ + Hash all the requirements found in the `requirements_file` + requirements file based on distributions available in `dest_dir` + """ + local_repo = get_local_repo(directory=dest_dir) + packages_by_normalized_name_version = local_repo.packages_by_normalized_name_version + hashed = [] + for name, version in load_requirements(requirements_file, force_pinned=True): + package = packages_by_normalized_name_version.get((name, version)) + if not package: + raise Exception(f'Missing required package {name}=={version}') + hashed.append(package.specifier_with_hashes) + + with open(requirements_file, 'w') as fo: + fo.write('\n'.join(hashed)) + +################################################################################ +# +# Functions to update or fetch ABOUT and license files +# +################################################################################ + + +def add_fetch_or_update_about_and_license_files(dest_dir=THIRDPARTY_DIR, include_remote=True): + """ + Given a thirdparty dir, add missing ABOUT. LICENSE and NOTICE files using + best efforts: + + - use existing ABOUT files + - try to load existing remote ABOUT files + - derive from existing distribution with same name and latest version that + would have such ABOUT file + - extract ABOUT file data from distributions PKGINFO or METADATA files + - TODO: make API calls to fetch package data from DejaCode + + The process consists in load and iterate on every package distributions, + collect data and then acsk to save. + """ + + local_packages = get_local_packages(directory=dest_dir) + local_repo = get_local_repo(directory=dest_dir) + + remote_repo = get_remote_repo() + + def get_other_dists(_package, _dist): + """ + Return a list of all the dists from package that are not the `dist` object + """ + return [d for d in _package.get_distributions() if d != _dist] + + for local_package in local_packages: + for local_dist in local_package.get_distributions(): + local_dist.load_about_data(dest_dir=dest_dir) + local_dist.set_checksums(dest_dir=dest_dir) + + # if has key data we may look to improve later, but we can move on + if local_dist.has_key_metadata(): + local_dist.save_about_and_notice_files(dest_dir=dest_dir) + local_dist.fetch_license_files(dest_dir=dest_dir) + continue + + # lets try to get from another dist of the same local package + for otherd in get_other_dists(local_package, local_dist): + updated = local_dist.update_from_other_dist(otherd) + if updated and local_dist.has_key_metadata(): + break + + # if has key data we may look to improve later, but we can move on + if local_dist.has_key_metadata(): + local_dist.save_about_and_notice_files(dest_dir=dest_dir) + local_dist.fetch_license_files(dest_dir=dest_dir) + continue + + # try to get a latest version of the same package that is not our version + other_local_packages = [ + p for p in local_repo.get_versions(local_package.name) + if p.version != local_package.version + ] + + latest_local_version = other_local_packages and other_local_packages[-1] + if latest_local_version: + latest_local_dists = list(latest_local_version.get_distributions()) + for latest_local_dist in latest_local_dists: + latest_local_dist.load_about_data(dest_dir=dest_dir) + if not latest_local_dist.has_key_metadata(): + # there is not much value to get other data if we are missing the key ones + continue + else: + local_dist.update_from_other_dist(latest_local_dist) + # if has key data we may look to improve later, but we can move on + if local_dist.has_key_metadata(): + break + + # if has key data we may look to improve later, but we can move on + if local_dist.has_key_metadata(): + local_dist.save_about_and_notice_files(dest_dir=dest_dir) + local_dist.fetch_license_files(dest_dir=dest_dir) + continue + + if include_remote: + # lets try to fetch remotely + local_dist.load_remote_about_data() + + # if has key data we may look to improve later, but we can move on + if local_dist.has_key_metadata(): + local_dist.save_about_and_notice_files(dest_dir=dest_dir) + local_dist.fetch_license_files(dest_dir=dest_dir) + continue + + # try to get a latest version of the same package that is not our version + other_remote_packages = [ + p for p in remote_repo.get_versions(local_package.name) + if p.version != local_package.version + ] + + latest_version = other_remote_packages and other_remote_packages[-1] + if latest_version: + latest_dists = list(latest_version.get_distributions()) + for remote_dist in latest_dists: + remote_dist.load_remote_about_data() + if not remote_dist.has_key_metadata(): + # there is not much value to get other data if we are missing the key ones + continue + else: + local_dist.update_from_other_dist(remote_dist) + # if has key data we may look to improve later, but we can move on + if local_dist.has_key_metadata(): + break + + # if has key data we may look to improve later, but we can move on + if local_dist.has_key_metadata(): + local_dist.save_about_and_notice_files(dest_dir=dest_dir) + local_dist.fetch_license_files(dest_dir=dest_dir) + continue + + # try to get data from pkginfo (no license though) + local_dist.load_pkginfo_data(dest_dir=dest_dir) + + # FIXME: save as this is the last resort for now in all cases + # if local_dist.has_key_metadata() or not local_dist.has_key_metadata(): + local_dist.save_about_and_notice_files(dest_dir) + + lic_errs = local_dist.fetch_license_files(dest_dir) + + # TODO: try to get data from dejacode + + if not local_dist.has_key_metadata(): + print(f'Unable to add essential ABOUT data for: {local_dist}') + if lic_errs: + lic_errs = '\n'.join(lic_errs) + print(f'Failed to fetch some licenses:: {lic_errs}') + +################################################################################ +# +# Functions to build new Python wheels including native on multiple OSes +# +################################################################################ + + +def call(args): + """ + Call args in a subprocess and display output on the fly. + Return or raise stdout, stderr, returncode + """ + if TRACE: print('Calling:', ' '.join(args)) + with subprocess.Popen( + args, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + encoding='utf-8' + ) as process: + + while True: + line = process.stdout.readline() + if not line and process.poll() is not None: + break + if TRACE: print(line.rstrip(), flush=True) + + stdout, stderr = process.communicate() + returncode = process.returncode + if returncode == 0: + return returncode, stdout, stderr + else: + raise Exception(returncode, stdout, stderr) + + +def add_or_upgrade_built_wheels( + name, + version=None, + python_versions=PYTHON_VERSIONS, + operating_systems=PLATFORMS_BY_OS, + dest_dir=THIRDPARTY_DIR, + build_remotely=False, + with_deps=False, + verbose=False, +): + """ + Add or update package `name` and `version` as a binary wheel saved in + `dest_dir`. Use the latest version if `version` is None. Return the a list + of the collected, fetched or built wheel file names or an empty list. + + Use the provided lists of `python_versions` (e.g. "36", "39") and + `operating_systems` (e.g. linux, windows or macos) to decide which specific + wheel to fetch or build. + + Include wheels for all dependencies if `with_deps` is True. + Build remotely is `build_remotely` is True. + """ + assert name, 'Name is required' + ver = version and f'=={version}' or '' + print(f'\nAdding wheels for package: {name}{ver}') + + wheel_filenames = [] + # a mapping of {req specifier: {mapping build_wheels kwargs}} + wheels_to_build = {} + for python_version, operating_system in itertools.product(python_versions, operating_systems): + print(f' Adding wheels for package: {name}{ver} on {python_version,} and {operating_system}') + environment = Environment.from_pyver_and_os(python_version, operating_system) + + # Check if requested wheel already exists locally for this version + local_repo = get_local_repo(directory=dest_dir) + local_package = local_repo.get_package(name=name, version=version) + + has_local_wheel = False + if version and local_package: + for wheel in local_package.get_supported_wheels(environment): + has_local_wheel = True + wheel_filenames.append(wheel.filename) + break + if has_local_wheel: + print(f' local wheel exists: {wheel.filename}') + continue + + if not version: + pypi_package = get_pypi_repo().get_latest_version(name) + version = pypi_package.version + + # Check if requested wheel already exists remotely or in Pypi for this version + wheel_filename = fetch_package_wheel( + name=name, version=version, environment=environment, dest_dir=dest_dir) + if wheel_filename: + wheel_filenames.append(wheel_filename) + + # the wheel is not available locally, remotely or in Pypi + # we need to build binary from sources + requirements_specifier = f'{name}=={version}' + to_build = wheels_to_build.get(requirements_specifier) + if to_build: + to_build['python_versions'].append(python_version) + to_build['operating_systems'].append(operating_system) + else: + wheels_to_build[requirements_specifier] = dict( + requirements_specifier=requirements_specifier, + python_versions=[python_version], + operating_systems=[operating_system], + dest_dir=dest_dir, + build_remotely=build_remotely, + with_deps=with_deps, + verbose=verbose, + ) + + for build_wheels_kwargs in wheels_to_build.values(): + bwheel_filenames = build_wheels(**build_wheels_kwargs) + wheel_filenames.extend(bwheel_filenames) + + return sorted(set(wheel_filenames)) + + +def build_wheels( + requirements_specifier, + python_versions=PYTHON_VERSIONS, + operating_systems=PLATFORMS_BY_OS, + dest_dir=THIRDPARTY_DIR, + build_remotely=False, + with_deps=False, + verbose=False, +): + """ + Given a pip `requirements_specifier` string (such as package names or as + name==version), build the corresponding binary wheel(s) for all + `python_versions` and `operating_systems` combinations and save them + back in `dest_dir` and return a list of built wheel file names. + + Include wheels for all dependencies if `with_deps` is True. + + First try to build locally to process pure Python wheels, and fall back to + build remotey on all requested Pythons and operating systems. + """ + all_pure, builds = build_wheels_locally_if_pure_python( + requirements_specifier=requirements_specifier, + with_deps=with_deps, + verbose=verbose, + dest_dir=dest_dir, + ) + for local_build in builds: + print(f'Built wheel: {local_build}') + + if all_pure: + return builds + + if build_remotely: + remote_builds = build_wheels_remotely_on_multiple_platforms( + requirements_specifier=requirements_specifier, + with_deps=with_deps, + python_versions=python_versions, + operating_systems=operating_systems, + verbose=verbose, + dest_dir=dest_dir, + ) + builds.extend(remote_builds) + + return builds + + +def build_wheels_remotely_on_multiple_platforms( + requirements_specifier, + with_deps=False, + python_versions=PYTHON_VERSIONS, + operating_systems=PLATFORMS_BY_OS, + verbose=False, + dest_dir=THIRDPARTY_DIR, +): + """ + Given pip `requirements_specifier` string (such as package names or as + name==version), build the corresponding binary wheel(s) including wheels for + all dependencies for all `python_versions` and `operating_systems` + combinations and save them back in `dest_dir` and return a list of built + wheel file names. + """ + check_romp_is_configured() + pyos_options = get_romp_pyos_options(python_versions, operating_systems) + deps = '' if with_deps else '--no-deps' + verbose = '--verbose' if verbose else '' + + romp_args = ([ + 'romp', + '--interpreter', 'cpython', + '--architecture', 'x86_64', + '--check-period', '5', # in seconds + + ] + pyos_options + [ + + '--artifact-paths', '*.whl', + '--artifact', 'artifacts.tar.gz', + '--command', + # create a virtualenv, upgrade pip +# f'python -m ensurepip --user --upgrade; ' + f'python -m pip {verbose} install --user --upgrade pip setuptools wheel; ' + f'python -m pip {verbose} wheel {deps} {requirements_specifier}', + ]) + + if verbose: + romp_args.append('--verbose') + + print(f'Building wheels for: {requirements_specifier}') + print(f'Using command:', ' '.join(romp_args)) + call(romp_args) + + wheel_filenames = extract_tar('artifacts.tar.gz', dest_dir) + for wfn in wheel_filenames: + print(f' built wheel: {wfn}') + return wheel_filenames + + +def get_romp_pyos_options( + python_versions=PYTHON_VERSIONS, + operating_systems=PLATFORMS_BY_OS, +): + """ + Return a list of CLI options for romp + For example: + >>> expected = ['--version', '3.6', '--version', '3.7', '--version', '3.8', + ... '--version', '3.9', '--platform', 'linux', '--platform', 'macos', + ... '--platform', 'windows'] + >>> assert get_romp_pyos_options() == expected + """ + python_dot_versions = ['.'.join(pv) for pv in sorted(set(python_versions))] + pyos_options = list(itertools.chain.from_iterable( + ('--version', ver) for ver in python_dot_versions)) + + pyos_options += list(itertools.chain.from_iterable( + ('--platform' , plat) for plat in sorted(set(operating_systems)))) + + return pyos_options + + +def check_romp_is_configured(): + # these environment variable must be set before + has_envt = ( + os.environ.get('ROMP_BUILD_REQUEST_URL') and + os.environ.get('ROMP_DEFINITION_ID') and + os.environ.get('ROMP_PERSONAL_ACCESS_TOKEN') and + os.environ.get('ROMP_USERNAME') + ) + + if not has_envt: + raise Exception( + 'ROMP_BUILD_REQUEST_URL, ROMP_DEFINITION_ID, ' + 'ROMP_PERSONAL_ACCESS_TOKEN and ROMP_USERNAME ' + 'are required enironment variables.') + + +def build_wheels_locally_if_pure_python( + requirements_specifier, + with_deps=False, + verbose=False, + dest_dir=THIRDPARTY_DIR, +): + """ + Given pip `requirements_specifier` string (such as package names or as + name==version), build the corresponding binary wheel(s) locally. + + If all these are "pure" Python wheels that run on all Python 3 versions and + operating systems, copy them back in `dest_dir` if they do not exists there + + Return a tuple of (True if all wheels are "pure", list of built wheel file names) + """ + deps = [] if with_deps else ['--no-deps'] + verbose = ['--verbose'] if verbose else [] + + wheel_dir = tempfile.mkdtemp(prefix='scancode-release-wheels-local-') + cli_args = [ + 'pip', 'wheel', + '--wheel-dir', wheel_dir, + ] + deps + verbose + [ + requirements_specifier + ] + + print(f'Building local wheels for: {requirements_specifier}') + print(f'Using command:', ' '.join(cli_args)) + call(cli_args) + + built = os.listdir(wheel_dir) + if not built: + return [] + + all_pure = all(is_pure_wheel(bwfn) for bwfn in built) + + if not all_pure: + print(f' Some wheels are not pure') + + print(f' Copying local wheels') + pure_built = [] + for bwfn in built: + owfn = os.path.join(dest_dir, bwfn) + if not os.path.exists(owfn): + nwfn = os.path.join(wheel_dir, bwfn) + fileutils.copyfile(nwfn, owfn) + pure_built.append(bwfn) + print(f' Built local wheel: {bwfn}') + return all_pure, pure_built + + +# TODO: Use me +def optimize_wheel(wheel_filename, dest_dir=THIRDPARTY_DIR): + """ + Optimize a wheel named `wheel_filename` in `dest_dir` such as renaming its + tags for PyPI compatibility and making it smaller if possible. Return the + name of the new wheel if renamed or the existing new name otherwise. + """ + if is_pure_wheel(wheel_filename): + print(f'Pure wheel: {wheel_filename}, nothing to do.') + return wheel_filename + + original_wheel_loc = os.path.join(dest_dir, wheel_filename) + wheel_dir = tempfile.mkdtemp(prefix='scancode-release-wheels-') + awargs = [ + 'auditwheel', + 'addtag', + '--wheel-dir', wheel_dir, + original_wheel_loc + ] + call(awargs) + + audited = os.listdir(wheel_dir) + if not audited: + # cannot optimize wheel + return wheel_filename + + assert len(audited) == 1 + new_wheel_name = audited[0] + + new_wheel_loc = os.path.join(wheel_dir, new_wheel_name) + + # this needs to go now + os.remove(original_wheel_loc) + + if new_wheel_name == wheel_filename: + os.rename(new_wheel_loc, original_wheel_loc) + return wheel_filename + + new_wheel = Wheel.from_filename(new_wheel_name) + non_pypi_plats = utils_pypi_supported_tags.validate_platforms_for_pypi(new_wheel.platforms) + new_wheel.platforms = [p for p in new_wheel.platforms if p not in non_pypi_plats] + if not new_wheel.platforms: + print(f'Cannot make wheel PyPI compatible: {original_wheel_loc}') + os.rename(new_wheel_loc, original_wheel_loc) + return wheel_filename + + new_wheel_cleaned_filename = new_wheel.to_filename() + new_wheel_cleaned_loc = os.path.join(dest_dir, new_wheel_cleaned_filename) + os.rename(new_wheel_loc, new_wheel_cleaned_loc) + return new_wheel_cleaned_filename + + +def extract_tar(location, dest_dir=THIRDPARTY_DIR,): + """ + Extract a tar archive at `location` in the `dest_dir` directory. Return a + list of extracted locations (either directories or files). + """ + with open(location, 'rb') as fi: + with tarfile.open(fileobj=fi) as tar: + members = list(tar.getmembers()) + tar.extractall(dest_dir, members=members) + + return [os.path.basename(ti.name) for ti in members + if ti.type == tarfile.REGTYPE] + + +def fetch_package_wheel(name, version, environment, dest_dir=THIRDPARTY_DIR): + """ + Fetch the binary wheel for package `name` and `version` and save in + `dest_dir`. Use the provided `environment` Environment to determine which + specific wheel to fetch. + + Return the fetched wheel file name on success or None if it was not fetched. + Trying fetching from our own remote repo, then from PyPI. + """ + wheel_filename = None + remote_package = get_remote_package(name=name, version=version) + if remote_package: + wheel_filename = remote_package.fetch_wheel( + environment=environment, dest_dir=dest_dir) + if wheel_filename: + return wheel_filename + + pypi_package = get_pypi_package(name=name, version=version) + if pypi_package: + wheel_filename = pypi_package.fetch_wheel( + environment=environment, dest_dir=dest_dir) + return wheel_filename + + +def check_about(dest_dir=THIRDPARTY_DIR): + try: + subprocess.check_output(f'bin/about check {dest_dir}'.split()) + except subprocess.CalledProcessError as cpe: + print() + print('Invalid ABOUT files:') + print(cpe.output.decode('utf-8', errors='replace')) + + +def find_problems( + dest_dir=THIRDPARTY_DIR, + report_missing_sources=False, + report_missing_wheels=False, +): + """ + Print the problems found in `dest_dir`. + """ + + local_packages = get_local_packages(directory=dest_dir) + + for package in local_packages: + if report_missing_sources and not package.sdist: + print(f'{package.name}=={package.version}: Missing source distribution.') + if report_missing_wheels and not package.wheels: + print(f'{package.name}=={package.version}: Missing wheels.') + + for dist in package.get_distributions(): + dist.load_about_data(dest_dir=dest_dir) + abpth = os.path.abspath(os.path.join(dest_dir, dist.about_filename)) + if not dist.has_key_metadata(): + print(f' Missing key ABOUT data in file://{abpth}') + if 'classifiers' in dist.extra_data: + print(f' Dangling classifiers data in file://{abpth}') + if not dist.validate_checksums(dest_dir): + print(f' Invalid checksums in file://{abpth}') + if not dist.sha1 and dist.md5: + print(f' Missing checksums in file://{abpth}') + + check_about(dest_dir=dest_dir) diff --git a/etc/scripts/utils_thirdparty.py.ABOUT b/etc/scripts/utils_thirdparty.py.ABOUT new file mode 100644 index 0000000..8480349 --- /dev/null +++ b/etc/scripts/utils_thirdparty.py.ABOUT @@ -0,0 +1,15 @@ +about_resource: utils_thirdparty.py +package_url: pkg:github.com/pypa/pip/@20.3.1#src/pip/_internal/models/wheel.py +type: github +namespace: pypa +name: pip +version: 20.3.1 +subpath: src/pip/_internal/models/wheel.py + +download_url: https://github.com/pypa/pip/blob/20.3.1/src/pip/_internal/models/wheel.py +copyright: Copyright (c) 2008-2020 The pip developers (see AUTHORS.txt file) +license_expression: mit +notes: copied from pip-20.3.1 pip/_internal/models/wheel.py + The models code has been heavily inspired from the ISC-licensed packaging-dists + https://github.com/uranusjr/packaging-dists by Tzu-ping Chung + \ No newline at end of file From 0e1f56b7cdb0a6a09b01111f6e69faafb7080af4 Mon Sep 17 00:00:00 2001 From: Jono Yang Date: Wed, 1 Sep 2021 19:00:28 -0700 Subject: [PATCH 05/30] Normalize license in load_pkginfo_data #33 * Create copyright statement from holder information Signed-off-by: Jono Yang --- etc/scripts/utils_thirdparty.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/etc/scripts/utils_thirdparty.py b/etc/scripts/utils_thirdparty.py index 360f07a..d5a6d99 100644 --- a/etc/scripts/utils_thirdparty.py +++ b/etc/scripts/utils_thirdparty.py @@ -894,14 +894,20 @@ def load_pkginfo_data(self, dest_dir=THIRDPARTY_DIR): classifiers = raw_data.get_all('Classifier') or [] declared_license = [raw_data['License']] + [c for c in classifiers if c.startswith('License')] + license_expression = compute_normalized_license_expression(declared_license) other_classifiers = [c for c in classifiers if not c.startswith('License')] + holder = raw_data['Author'] + holder_contact=raw_data['Author-email'] + copyright = f'Copyright {holder} <{holder_contact}>' pkginfo_data = dict( name=raw_data['Name'], declared_license=declared_license, version=raw_data['Version'], description=raw_data['Summary'], homepage_url=raw_data['Home-page'], + copyright=copyright, + license_expression=license_expression, holder=raw_data['Author'], holder_contact=raw_data['Author-email'], keywords=raw_data['Keywords'], @@ -2938,3 +2944,25 @@ def find_problems( print(f' Missing checksums in file://{abpth}') check_about(dest_dir=dest_dir) + + +def compute_normalized_license_expression(declared_licenses): + if not declared_licenses: + return + + from packagedcode import licensing + from packagedcode.utils import combine_expressions + + detected_licenses = [] + for declared in declared_licenses: + try: + license_expression = licensing.get_normalized_expression( + query_string=declared + ) + except Exception: + return 'unknown' + if not license_expression: + continue + detected_licenses.append(license_expression) + if detected_licenses: + return combine_expressions(detected_licenses) From 288532d448e5740519f69c8b210a95524e5ec538 Mon Sep 17 00:00:00 2001 From: Jono Yang Date: Thu, 2 Sep 2021 14:57:09 -0700 Subject: [PATCH 06/30] Add --init option to configure #33 * This is used for the case where we are starting off a project and have not yet generated requirements files Signed-off-by: Jono Yang --- configure | 7 +- etc/scripts/gen_pypi_simple.py | 191 ++++++++++++++++++++++++++ etc/scripts/gen_pypi_simple.py.ABOUT | 8 ++ etc/scripts/gen_pypi_simple.py.NOTICE | 56 ++++++++ etc/scripts/requirements.txt | 12 ++ etc/scripts/utils_thirdparty.py | 30 ++-- 6 files changed, 281 insertions(+), 23 deletions(-) create mode 100644 etc/scripts/gen_pypi_simple.py create mode 100644 etc/scripts/gen_pypi_simple.py.ABOUT create mode 100644 etc/scripts/gen_pypi_simple.py.NOTICE create mode 100644 etc/scripts/requirements.txt diff --git a/configure b/configure index 66d939a..bbe87b0 100755 --- a/configure +++ b/configure @@ -53,9 +53,6 @@ CFG_BIN_DIR=$CFG_ROOT_DIR/$VIRTUALENV_DIR/bin # Find packages from the local thirdparty directory or from thirdparty.aboutcode.org PIP_EXTRA_ARGS="--find-links $CFG_ROOT_DIR/thirdparty --find-links https://thirdparty.aboutcode.org/pypi" -if [[ -f "$CFG_ROOT_DIR/requirements.txt" ]] && [[ -f "$CFG_ROOT_DIR/requirements-dev.txt" ]]; then - PIP_EXTRA_ARGS+=" --no-index" -fi ################################ # Set the quiet flag to empty if not defined @@ -161,13 +158,17 @@ install_packages() { # Main command line entry point CFG_DEV_MODE=0 CFG_REQUIREMENTS=$REQUIREMENTS +NO_INDEX="--no-index" case "$CLI_ARGS" in --help) cli_help;; --clean) clean;; --dev) CFG_REQUIREMENTS="$DEV_REQUIREMENTS" && CFG_DEV_MODE=1;; + --init) NO_INDEX="";; esac +PIP_EXTRA_ARGS="$PIP_EXTRA_ARGS $NO_INDEX" + create_virtualenv "$VIRTUALENV_DIR" install_packages "$CFG_REQUIREMENTS" . "$CFG_BIN_DIR/activate" diff --git a/etc/scripts/gen_pypi_simple.py b/etc/scripts/gen_pypi_simple.py new file mode 100644 index 0000000..887e407 --- /dev/null +++ b/etc/scripts/gen_pypi_simple.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# SPDX-License-Identifier: BSD-2-Clause-Views AND MIT +# Copyright (c) 2010 David Wolever . All rights reserved. +# originally from https://github.com/wolever/pip2pi + +import os +import re +import shutil + +from html import escape +from pathlib import Path + +""" +name: pip compatibility tags +version: 20.3.1 +download_url: https://github.com/pypa/pip/blob/20.3.1/src/pip/_internal/models/wheel.py +copyright: Copyright (c) 2008-2020 The pip developers (see AUTHORS.txt file) +license_expression: mit +notes: the weel name regex is copied from pip-20.3.1 pip/_internal/models/wheel.py + +Copyright (c) 2008-2020 The pip developers (see AUTHORS.txt file) + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +""" +get_wheel_from_filename = re.compile( + r"""^(?P(?P.+?)-(?P.*?)) + ((-(?P\d[^-]*?))?-(?P.+?)-(?P.+?)-(?P.+?) + \.whl)$""", + re.VERBOSE +).match + +sdist_exts = ".tar.gz", ".tar.bz2", ".zip", ".tar.xz", +wheel_ext = ".whl" +app_ext = ".pyz" +dist_exts = sdist_exts + (wheel_ext, app_ext) + + +class InvalidDistributionFilename(Exception): + pass + + +def get_package_name_from_filename(filename, normalize=True): + """ + Return the package name extracted from a package ``filename``. + Optionally ``normalize`` the name according to distribution name rules. + Raise an ``InvalidDistributionFilename`` if the ``filename`` is invalid:: + + >>> get_package_name_from_filename("foo-1.2.3_rc1.tar.gz") + 'foo' + >>> get_package_name_from_filename("foo-bar-1.2-py27-none-any.whl") + 'foo-bar' + >>> get_package_name_from_filename("Cython-0.17.2-cp26-none-linux_x86_64.whl") + 'cython' + >>> get_package_name_from_filename("python_ldap-2.4.19-cp27-none-macosx_10_10_x86_64.whl") + 'python-ldap' + >>> get_package_name_from_filename("foo.whl") + Traceback (most recent call last): + ... + InvalidDistributionFilename: ... + >>> get_package_name_from_filename("foo.png") + Traceback (most recent call last): + ... + InvalidFilePackageName: ... + """ + if not filename or not filename.endswith(dist_exts): + raise InvalidDistributionFilename(filename) + + filename = os.path.basename(filename) + + if filename.endswith(sdist_exts): + name_ver = None + extension = None + + for ext in sdist_exts: + if filename.endswith(ext): + name_ver, extension, _ = filename.rpartition(ext) + break + + if not extension or not name_ver: + raise InvalidDistributionFilename(filename) + + name, _, version = name_ver.rpartition('-') + + if not (name and version): + raise InvalidDistributionFilename(filename) + + elif filename.endswith(wheel_ext): + + wheel_info = get_wheel_from_filename(filename) + + if not wheel_info: + raise InvalidDistributionFilename(filename) + + name = wheel_info.group('name') + version = wheel_info.group('version') + + if not (name and version): + raise InvalidDistributionFilename(filename) + + elif filename.endswith(app_ext): + name_ver, extension, _ = filename.rpartition(".pyz") + + if "-" in filename: + name, _, version = name_ver.rpartition('-') + else: + name = name_ver + + if not name: + raise InvalidDistributionFilename(filename) + + if normalize: + name = name.lower().replace('_', '-') + return name + + +def build_pypi_index(directory, write_index=False): + """ + Using a ``directory`` directory of wheels and sdists, create the a PyPI simple + directory index at ``directory``/simple/ populated with the proper PyPI simple + index directory structure crafted using symlinks. + + WARNING: The ``directory``/simple/ directory is removed if it exists. + """ + + directory = Path(directory) + + index_dir = directory / "simple" + if index_dir.exists(): + shutil.rmtree(str(index_dir), ignore_errors=True) + + index_dir.mkdir(parents=True) + + if write_index: + simple_html_index = [ + "PyPI Simple Index", + "", + ] + + package_names = set() + for pkg_file in directory.iterdir(): + + pkg_filename = pkg_file.name + + if ( + not pkg_file.is_file() + or not pkg_filename.endswith(dist_exts) + or pkg_filename.startswith(".") + ): + continue + + pkg_name = get_package_name_from_filename(pkg_filename) + pkg_index_dir = index_dir / pkg_name + pkg_index_dir.mkdir(parents=True, exist_ok=True) + pkg_indexed_file = pkg_index_dir / pkg_filename + link_target = Path("../..") / pkg_filename + pkg_indexed_file.symlink_to(link_target) + + if write_index and pkg_name not in package_names: + esc_name = escape(pkg_name) + simple_html_index.append(f'{esc_name}
') + package_names.add(pkg_name) + + if write_index: + simple_html_index.append("") + index_html = index_dir / "index.html" + index_html.write_text("\n".join(simple_html_index)) + + +if __name__ == "__main__": + import sys + pkg_dir = sys.argv[1] + build_pypi_index(pkg_dir) diff --git a/etc/scripts/gen_pypi_simple.py.ABOUT b/etc/scripts/gen_pypi_simple.py.ABOUT new file mode 100644 index 0000000..4de5ded --- /dev/null +++ b/etc/scripts/gen_pypi_simple.py.ABOUT @@ -0,0 +1,8 @@ +about_resource: gen_pypi_simple.py +name: gen_pypi_simple.py +license_expression: bsd-2-clause-views and mit +copyright: Copyright (c) nexB Inc. + Copyright (c) 2010 David Wolever + Copyright (c) The pip developers +notes: Originally from https://github.com/wolever/pip2pi and modified extensivley + Also partially derived from pip code diff --git a/etc/scripts/gen_pypi_simple.py.NOTICE b/etc/scripts/gen_pypi_simple.py.NOTICE new file mode 100644 index 0000000..6e0fbbc --- /dev/null +++ b/etc/scripts/gen_pypi_simple.py.NOTICE @@ -0,0 +1,56 @@ +SPDX-License-Identifier: BSD-2-Clause-Views AND mit + +Copyright (c) nexB Inc. +Copyright (c) 2010 David Wolever +Copyright (c) The pip developers + + +Original code: copyright 2010 David Wolever . All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO +EVENT SHALL OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +The views and conclusions contained in the software and documentation are those +of the authors and should not be interpreted as representing official policies, +either expressed or implied, of David Wolever. + + +Original code: Copyright (c) 2008-2020 The pip developers + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/etc/scripts/requirements.txt b/etc/scripts/requirements.txt new file mode 100644 index 0000000..6591e49 --- /dev/null +++ b/etc/scripts/requirements.txt @@ -0,0 +1,12 @@ +aboutcode_toolkit +github-release-retry2 +attrs +commoncode +click +requests +saneyaml +romp +pip +setuptools +twine +wheel \ No newline at end of file diff --git a/etc/scripts/utils_thirdparty.py b/etc/scripts/utils_thirdparty.py index d5a6d99..c0613c3 100644 --- a/etc/scripts/utils_thirdparty.py +++ b/etc/scripts/utils_thirdparty.py @@ -899,7 +899,8 @@ def load_pkginfo_data(self, dest_dir=THIRDPARTY_DIR): holder = raw_data['Author'] holder_contact=raw_data['Author-email'] - copyright = f'Copyright {holder} <{holder_contact}>' + copyright = f'Copyright (c) {holder} <{holder_contact}>' + pkginfo_data = dict( name=raw_data['Name'], declared_license=declared_license, @@ -908,8 +909,8 @@ def load_pkginfo_data(self, dest_dir=THIRDPARTY_DIR): homepage_url=raw_data['Home-page'], copyright=copyright, license_expression=license_expression, - holder=raw_data['Author'], - holder_contact=raw_data['Author-email'], + holder=holder, + holder_contact=holder_contact, keywords=raw_data['Keywords'], classifiers=other_classifiers, ) @@ -2949,20 +2950,9 @@ def find_problems( def compute_normalized_license_expression(declared_licenses): if not declared_licenses: return - - from packagedcode import licensing - from packagedcode.utils import combine_expressions - - detected_licenses = [] - for declared in declared_licenses: - try: - license_expression = licensing.get_normalized_expression( - query_string=declared - ) - except Exception: - return 'unknown' - if not license_expression: - continue - detected_licenses.append(license_expression) - if detected_licenses: - return combine_expressions(detected_licenses) + try: + from packagedcode import pypi + return pypi.compute_normalized_license(declared_licenses) + except ImportError: + # Scancode is not installed, we join all license strings and return it + return ' '.join(declared_licenses) From a5ae4f35473a38427f3fba9b225b6de0ec16522c Mon Sep 17 00:00:00 2001 From: Jono Yang Date: Thu, 2 Sep 2021 16:48:04 -0700 Subject: [PATCH 07/30] Update README.rst #33 Signed-off-by: Jono Yang --- README.rst | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index b84a049..a52d805 100644 --- a/README.rst +++ b/README.rst @@ -2,7 +2,7 @@ A Simple Python Project Skeleton ================================ This repo attempts to standardize our python repositories using modern python packaging and configuration techniques. Using this `blog post`_ as inspiration, this -repository will serve as the base for all new python projects and will be adopted to all +repository will serve as the base for all new python projects and will be adopted to all our existing ones as well. .. _blog post: https://blog.jaraco.com/a-project-skeleton-for-python-projects/ @@ -33,7 +33,6 @@ Update an existing project This is also the workflow to use when updating the skeleton files in any given repository. - Customizing ----------- @@ -42,6 +41,72 @@ You typically want to perform these customizations: - remove or update the src/README.rst and tests/README.rst files - check the configure and configure.bat defaults +Initializing a project +---------------------- + +All projects using the skeleton will be expected to pull all of it dependencies +from thirdparty.aboutcode.org/pypi or the local thirdparty directory, using +requirements.txt and/or requirements-dev.txt to determine what version of a +package to collect. By default, PyPI will not be used to find and collect +packages from. + +In the case where we are starting a new project where we do not have +requirements.txt and requirements-dev.txt and whose dependencies are not yet on +thirdparty.aboutcode.org/pypi, we run the following command after adding and +customizing the skeleton files to your project: + +.. code-block:: bash + + ./configure --init + +This will initialize the virtual environment for the project, pull in the +dependencies from PyPI and add them to the virtual environment. + +Generating requirements.txt and requirements-dev.txt +---------------------------------------------------- + +After the project has been initialized, we can generate the requirements.txt and +requirements-dev.txt files. + +Ensure the virtual environment is enabled. + +To generate requirements.txt: + +.. code-block:: bash + + python etc/scripts/gen_requirements.py -s tmp/lib/python/site-packages/ + +Replace \ with the version number of the Python being used. + +To generate requirements-dev.txt after requirements.txt has been generated: + +.. code-block:: bash + ./configure --dev + source tmp/bin/activate + python etc/scripts/gen_requirements_dev.py -s tmp/lib/python/site-packages/ + +Collecting and generating ABOUT files for dependencies +------------------------------------------------------ + +Once we have requirements.txt and requirements-dev.txt, we can fetch the project +dependencies as wheels and generate ABOUT files for them: + +.. code-block:: bash + + python etc/scripts/bootstrap.py -r requirements.txt -r requirements-dev.txt --with-deps + +There may be issues with the generated ABOUT files, which will have to be +corrected. You can check to see if your corrections are valid by running: + +.. code-block:: bash + + python etc/scripts/check_thirdparty.py -d thirdparty + +Once the wheels are collected and the ABOUT files are generated and correct, +upload them to thirdparty.aboutcode.org/pypi by placing the wheels and ABOUT +files from the thirdparty directory to the pypi directory at +https://github.com/nexB/thirdparty-packages + Release Notes ------------- From 593e2379c688e92985a3c6eceabf69cb721207a5 Mon Sep 17 00:00:00 2001 From: Jono Yang Date: Thu, 2 Sep 2021 17:09:06 -0700 Subject: [PATCH 08/30] Use venv as virtual environment directory name #37 * Replace all references to `tmp` with `venv` Signed-off-by: Jono Yang --- .gitignore | 1 + .travis.yml | 2 +- README.rst | 6 +++--- azure-pipelines.yml | 14 +++++++------- configure | 4 ++-- configure.bat | 4 ++-- pyproject.toml | 3 ++- 7 files changed, 18 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 68de2d2..339dca5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ /Lib /pip-selfcheck.json /tmp +/venv .Python /include /Include diff --git a/.travis.yml b/.travis.yml index 1a90a38..ea48ceb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,4 +19,4 @@ python: install: ./configure --dev # Scripts to run at script stage -script: tmp/bin/pytest +script: venv/bin/pytest diff --git a/README.rst b/README.rst index a52d805..08ef083 100644 --- a/README.rst +++ b/README.rst @@ -74,7 +74,7 @@ To generate requirements.txt: .. code-block:: bash - python etc/scripts/gen_requirements.py -s tmp/lib/python/site-packages/ + python etc/scripts/gen_requirements.py -s venv/lib/python/site-packages/ Replace \ with the version number of the Python being used. @@ -82,8 +82,8 @@ To generate requirements-dev.txt after requirements.txt has been generated: .. code-block:: bash ./configure --dev - source tmp/bin/activate - python etc/scripts/gen_requirements_dev.py -s tmp/lib/python/site-packages/ + source venv/bin/activate + python etc/scripts/gen_requirements_dev.py -s venv/lib/python/site-packages/ Collecting and generating ABOUT files for dependencies ------------------------------------------------------ diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 31ef36f..22c12c4 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -13,7 +13,7 @@ jobs: image_name: ubuntu-16.04 python_versions: ['3.6', '3.7', '3.8', '3.9'] test_suites: - all: tmp/bin/pytest -vvs + all: venv/bin/pytest -vvs - template: etc/ci/azure-posix.yml parameters: @@ -21,7 +21,7 @@ jobs: image_name: ubuntu-18.04 python_versions: ['3.6', '3.7', '3.8', '3.9'] test_suites: - all: tmp/bin/pytest -n 2 -vvs + all: venv/bin/pytest -n 2 -vvs - template: etc/ci/azure-posix.yml parameters: @@ -29,7 +29,7 @@ jobs: image_name: ubuntu-20.04 python_versions: ['3.6', '3.7', '3.8', '3.9'] test_suites: - all: tmp/bin/pytest -n 2 -vvs + all: venv/bin/pytest -n 2 -vvs - template: etc/ci/azure-posix.yml parameters: @@ -37,7 +37,7 @@ jobs: image_name: macos-10.14 python_versions: ['3.6', '3.7', '3.8', '3.9'] test_suites: - all: tmp/bin/pytest -n 2 -vvs + all: venv/bin/pytest -n 2 -vvs - template: etc/ci/azure-posix.yml parameters: @@ -45,7 +45,7 @@ jobs: image_name: macos-10.15 python_versions: ['3.6', '3.7', '3.8', '3.9'] test_suites: - all: tmp/bin/pytest -n 2 -vvs + all: venv/bin/pytest -n 2 -vvs - template: etc/ci/azure-win.yml parameters: @@ -53,7 +53,7 @@ jobs: image_name: vs2017-win2016 python_versions: ['3.6', '3.7', '3.8', '3.9'] test_suites: - all: tmp\Scripts\pytest -n 2 -vvs + all: venv\Scripts\pytest -n 2 -vvs - template: etc/ci/azure-win.yml parameters: @@ -61,4 +61,4 @@ jobs: image_name: windows-2019 python_versions: ['3.6', '3.7', '3.8', '3.9'] test_suites: - all: tmp\Scripts\pytest -n 2 -vvs + all: venv\Scripts\pytest -n 2 -vvs diff --git a/configure b/configure index bbe87b0..7c162c7 100755 --- a/configure +++ b/configure @@ -30,12 +30,12 @@ REQUIREMENTS="--editable . --constraint requirements.txt" DEV_REQUIREMENTS="--editable .[testing] --constraint requirements.txt --constraint requirements-dev.txt" # where we create a virtualenv -VIRTUALENV_DIR=tmp +VIRTUALENV_DIR=venv # Cleanable files and directories with the --clean option CLEANABLE=" build - tmp" + venv" # extra arguments passed to pip PIP_EXTRA_ARGS=" " diff --git a/configure.bat b/configure.bat index 75cab5f..529c371 100644 --- a/configure.bat +++ b/configure.bat @@ -28,10 +28,10 @@ set "REQUIREMENTS=--editable . --constraint requirements.txt" set "DEV_REQUIREMENTS=--editable .[testing] --constraint requirements.txt --constraint requirements-dev.txt" @rem # where we create a virtualenv -set "VIRTUALENV_DIR=tmp" +set "VIRTUALENV_DIR=venv" @rem # Cleanable files and directories to delete with the --clean option -set "CLEANABLE=build tmp" +set "CLEANABLE=build venv" @rem # extra arguments passed to pip set "PIP_EXTRA_ARGS= " diff --git a/pyproject.toml b/pyproject.toml index 852f0fc..1e10f32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,10 +33,11 @@ norecursedirs = [ "Scripts", "thirdparty", "tmp", + "venv", "tests/data", ".eggs" ] - + python_files = "*.py" python_classes = "Test" From 9342bc1057da73bf39f1b0cb86b85bc581a76793 Mon Sep 17 00:00:00 2001 From: Jono Yang Date: Thu, 2 Sep 2021 18:26:29 -0700 Subject: [PATCH 09/30] Update configure.bat #33 * Add --init option to configure.bat * Update help text in configure and configure.bat Signed-off-by: Jono Yang --- configure | 4 ++++ configure.bat | 16 +++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/configure b/configure index 7c162c7..3c60788 100755 --- a/configure +++ b/configure @@ -81,10 +81,14 @@ cli_help() { echo " usage: ./configure [options]" echo echo The default is to configure for regular use. Use --dev for development. + echo Use the --init option if starting a new project and the project + echo dependencies are not available on thirdparty.aboutcode.org/pypi/ + echo and requirements.txt and/or requirements-dev.txt has not been generated. echo echo The options are: echo " --clean: clean built and installed files and exit." echo " --dev: configure the environment for development." + echo " --init: pull dependencies from PyPI. Used when first setting up a project." echo " --help: display this help message and exit." echo echo By default, the python interpreter version found in the path is used. diff --git a/configure.bat b/configure.bat index 529c371..dc6db8b 100644 --- a/configure.bat +++ b/configure.bat @@ -49,11 +49,6 @@ set "CFG_BIN_DIR=%CFG_ROOT_DIR%\%VIRTUALENV_DIR%\Scripts" @rem ################################ @rem # Thirdparty package locations and index handling -if exist ""%CFG_ROOT_DIR%\requirements.txt"" if exist ""%CFG_ROOT_DIR%\requirements-dev.txt"" ( - set "INDEX_ARG= --no-index" -) else ( - set "INDEX_ARG= " -) set "PIP_EXTRA_ARGS=--find-links %CFG_ROOT_DIR%\thirdparty --find-links https://thirdparty.aboutcode.org/pypi" & %INDEX_ARG% @rem ################################ @@ -69,6 +64,7 @@ if not defined CFG_QUIET ( @rem # Main command line entry point set CFG_DEV_MODE=0 set "CFG_REQUIREMENTS=%REQUIREMENTS%" +set "NO_INDEX=--no-index" if "%1" EQU "--help" (goto cli_help) if "%1" EQU "--clean" (goto clean) @@ -76,12 +72,18 @@ if "%1" EQU "--dev" ( set "CFG_REQUIREMENTS=%DEV_REQUIREMENTS%" set CFG_DEV_MODE=1 ) +if "%1" EQU "--init" ( + set "NO_INDEX= " +) if "%1" EQU "--python" ( echo "The --python option is now DEPRECATED. Use the PYTHON_EXECUTABLE environment" echo "variable instead. Run configure --help for details." exit /b 0 ) +set "PIP_EXTRA_ARGS=%PIP_EXTRA_ARGS% %NO_INDEX%" + + @rem ################################ @rem # find a proper Python to run @rem # Use environment variables or a file if available. @@ -170,10 +172,14 @@ exit /b 0 echo " usage: configure [options]" echo " " echo The default is to configure for regular use. Use --dev for development. + echo Use the --init option if starting a new project and the project + echo dependencies are not available on thirdparty.aboutcode.org/pypi/ + echo and requirements.txt and/or requirements-dev.txt has not been generated. echo " " echo The options are: echo " --clean: clean built and installed files and exit." echo " --dev: configure the environment for development." + echo " --init: pull dependencies from PyPI. Used when first setting up a project." echo " --help: display this help message and exit." echo " " echo By default, the python interpreter version found in the path is used. From 45e4a2aaf2e887f1ccade825c323be68bad7d127 Mon Sep 17 00:00:00 2001 From: Jono Yang Date: Thu, 2 Sep 2021 18:42:03 -0700 Subject: [PATCH 10/30] Add placeholder requirements.txt files #33 Signed-off-by: Jono Yang --- requirements-dev.txt | 0 requirements.txt | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 requirements-dev.txt create mode 100644 requirements.txt diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29 From 944fbaee4ee4c317252ecdafe36af946dc58a9b8 Mon Sep 17 00:00:00 2001 From: Jono Yang Date: Fri, 3 Sep 2021 14:33:56 -0700 Subject: [PATCH 11/30] Handle multiple options in configure #33 Signed-off-by: Jono Yang --- README.rst | 4 ++-- configure | 18 ++++++++++++------ configure.bat | 31 ++++++++++++++++++------------- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/README.rst b/README.rst index 08ef083..78ab9f4 100644 --- a/README.rst +++ b/README.rst @@ -76,12 +76,12 @@ To generate requirements.txt: python etc/scripts/gen_requirements.py -s venv/lib/python/site-packages/ -Replace \ with the version number of the Python being used. +Replace \ with the version number of the Python being used, for example: ``venv/lib/python3.6/site-packages/`` To generate requirements-dev.txt after requirements.txt has been generated: .. code-block:: bash - ./configure --dev + ./configure --init --dev source venv/bin/activate python etc/scripts/gen_requirements_dev.py -s venv/lib/python/site-packages/ diff --git a/configure b/configure index 3c60788..b965692 100755 --- a/configure +++ b/configure @@ -164,12 +164,18 @@ CFG_DEV_MODE=0 CFG_REQUIREMENTS=$REQUIREMENTS NO_INDEX="--no-index" -case "$CLI_ARGS" in - --help) cli_help;; - --clean) clean;; - --dev) CFG_REQUIREMENTS="$DEV_REQUIREMENTS" && CFG_DEV_MODE=1;; - --init) NO_INDEX="";; -esac +# We are using getopts to parse option arguments that start with "-" +while getopts :-: optchar; do + case "${optchar}" in + -) + case "${OPTARG}" in + help ) cli_help;; + clean ) clean;; + dev ) CFG_REQUIREMENTS="$DEV_REQUIREMENTS" && CFG_DEV_MODE=1;; + init ) NO_INDEX="";; + esac;; + esac +done PIP_EXTRA_ARGS="$PIP_EXTRA_ARGS $NO_INDEX" diff --git a/configure.bat b/configure.bat index dc6db8b..31f91c4 100644 --- a/configure.bat +++ b/configure.bat @@ -66,19 +66,24 @@ set CFG_DEV_MODE=0 set "CFG_REQUIREMENTS=%REQUIREMENTS%" set "NO_INDEX=--no-index" -if "%1" EQU "--help" (goto cli_help) -if "%1" EQU "--clean" (goto clean) -if "%1" EQU "--dev" ( - set "CFG_REQUIREMENTS=%DEV_REQUIREMENTS%" - set CFG_DEV_MODE=1 -) -if "%1" EQU "--init" ( - set "NO_INDEX= " -) -if "%1" EQU "--python" ( - echo "The --python option is now DEPRECATED. Use the PYTHON_EXECUTABLE environment" - echo "variable instead. Run configure --help for details." - exit /b 0 +:again +if not "%1" == "" ( + if "%1" EQU "--help" (goto cli_help) + if "%1" EQU "--clean" (goto clean) + if "%1" EQU "--dev" ( + set "CFG_REQUIREMENTS=%DEV_REQUIREMENTS%" + set CFG_DEV_MODE=1 + ) + if "%1" EQU "--init" ( + set "NO_INDEX= " + ) + if "%1" EQU "--python" ( + echo "The --python option is now DEPRECATED. Use the PYTHON_EXECUTABLE environment" + echo "variable instead. Run configure --help for details." + exit /b 0 + ) + shift + goto again ) set "PIP_EXTRA_ARGS=%PIP_EXTRA_ARGS% %NO_INDEX%" From 3532b22ed0bb15e77dc315c527908dc87629e0e2 Mon Sep 17 00:00:00 2001 From: Jono Yang Date: Fri, 3 Sep 2021 17:01:20 -0700 Subject: [PATCH 12/30] Fix path to aboutcode in utils_thirdparty.py #33 * Update README.rst Signed-off-by: Jono Yang --- README.rst | 11 ++++++++++- etc/scripts/utils_thirdparty.py | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 78ab9f4..5853bf5 100644 --- a/README.rst +++ b/README.rst @@ -70,6 +70,10 @@ requirements-dev.txt files. Ensure the virtual environment is enabled. +.. code-block:: bash + + source venv/bin/activate + To generate requirements.txt: .. code-block:: bash @@ -82,12 +86,17 @@ To generate requirements-dev.txt after requirements.txt has been generated: .. code-block:: bash ./configure --init --dev - source venv/bin/activate python etc/scripts/gen_requirements_dev.py -s venv/lib/python/site-packages/ Collecting and generating ABOUT files for dependencies ------------------------------------------------------ +Ensure that the dependencies used by ``etc/scripts/bootstrap.py`` are installed: + +.. code-block:: bash + + pip install -r etc/scripts/requirements.txt + Once we have requirements.txt and requirements-dev.txt, we can fetch the project dependencies as wheels and generate ABOUT files for them: diff --git a/etc/scripts/utils_thirdparty.py b/etc/scripts/utils_thirdparty.py index c0613c3..23e837f 100644 --- a/etc/scripts/utils_thirdparty.py +++ b/etc/scripts/utils_thirdparty.py @@ -2908,7 +2908,7 @@ def fetch_package_wheel(name, version, environment, dest_dir=THIRDPARTY_DIR): def check_about(dest_dir=THIRDPARTY_DIR): try: - subprocess.check_output(f'bin/about check {dest_dir}'.split()) + subprocess.check_output(f'venv/bin/about check {dest_dir}'.split()) except subprocess.CalledProcessError as cpe: print() print('Invalid ABOUT files:') From 9c78ddb5100dec3e6c57079bb3c06fbdc7b79b1c Mon Sep 17 00:00:00 2001 From: Jono Yang Date: Fri, 3 Sep 2021 17:34:38 -0700 Subject: [PATCH 13/30] Update release notes in README.rst Signed-off-by: Jono Yang --- README.rst | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 5853bf5..40226e0 100644 --- a/README.rst +++ b/README.rst @@ -39,6 +39,7 @@ Customizing You typically want to perform these customizations: - remove or update the src/README.rst and tests/README.rst files +- set project info and dependencies in setup.cfg - check the configure and configure.bat defaults Initializing a project @@ -118,7 +119,15 @@ https://github.com/nexB/thirdparty-packages Release Notes -------------- - -- 2021-05-11: adopt new configure scripts from ScanCode TK that allows correct - configuration of which Python version is used. +============= + +- 2021-09-03: + - ``configure`` now requires pinned dependencies via the use of ``requirements.txt`` and ``requirements-dev.txt`` + - ``configure`` can now accept multiple options at once + - Add utility scripts from scancode-toolkit/etc/release/ for use in generating project files + - Rename virtual environment directory from ``tmp`` to ``venv`` + - Update README.rst with instructions for generating ``requirements.txt`` and ``requirements-dev.txt``, + as well as collecting dependencies as wheels and generating ABOUT files for them. + +- 2021-05-11: + - Adopt new configure scripts from ScanCode TK that allows correct configuration of which Python version is used. From ebcfb933a7483d0a7cd1fc02d724cec5ef9b2d28 Mon Sep 17 00:00:00 2001 From: Jono Yang Date: Fri, 3 Sep 2021 18:59:49 -0700 Subject: [PATCH 14/30] Handle ExpressionParseError #33 * Update README.rst with instructions for post-initialization usage Signed-off-by: Jono Yang --- README.rst | 47 +++++++++++++++++++++++++++++++++ etc/scripts/utils_thirdparty.py | 6 ++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 40226e0..b15be20 100644 --- a/README.rst +++ b/README.rst @@ -89,6 +89,14 @@ To generate requirements-dev.txt after requirements.txt has been generated: ./configure --init --dev python etc/scripts/gen_requirements_dev.py -s venv/lib/python/site-packages/ +Note: on Windows, the ``site-packages`` directory is located at ``venv\Lib\site-packages\`` + +.. code-block:: bash + + python .\\etc\\scripts\\gen_requirements.py -s .\\venv\\Lib\\site-packages\\ + .\configure --init --dev + python .\\etc\\scripts\\gen_requirements_dev.py -s .\\venv\\Lib\\site-packages\\ + Collecting and generating ABOUT files for dependencies ------------------------------------------------------ @@ -118,6 +126,45 @@ files from the thirdparty directory to the pypi directory at https://github.com/nexB/thirdparty-packages +Usage after project initialization +---------------------------------- + +Once the ``requirements.txt`` and ``requirements-dev.txt`` has been generated +and the project dependencies and their ABOUT files have been uploaded to +thirdparty.aboutcode.org/pypi, you can configure the project without using the +``--init`` option. + +If the virtual env for the project becomes polluted, or you would like to remove +it, use the ``--clean`` option: + +.. code-block:: bash + + ./configure --clean + +Then you can run ``./configure`` again to set up the project virtual environment. + +To set up the project for development use: + +.. code-block:: bash + + ./configure --dev + +To update the project dependencies (adding, removing, updating packages, etc.), +update the dependencies in ``setup.cfg``, then run: + +.. code-block:: bash + + ./configure --clean # Remove existing virtual environment + ./configure --init # Create project virtual environment, pull in new dependencies + source venv/bin/activate # Ensure virtual environment is activated + python etc/scripts/gen_requirements.py -s venv/lib/python/site-packages/ # Regenerate requirements.txt + python etc/scripts/gen_requirements_dev.py -s venv/lib/python/site-packages/ # Regenerate requirements-dev.txt + pip install -r etc/scripts/requirements.txt # Install dependencies needed by etc/scripts/bootstrap.py + python etc/scripts/bootstrap.py -r requirements.txt -r requirements-dev.txt --with-deps # Collect dependency wheels and their ABOUT files + +Ensure that the generated ABOUT files are valid, then take the dependency wheels +and ABOUT files and upload them to thirdparty.aboutcode.org/pypi. + Release Notes ============= diff --git a/etc/scripts/utils_thirdparty.py b/etc/scripts/utils_thirdparty.py index 23e837f..978f0e1 100644 --- a/etc/scripts/utils_thirdparty.py +++ b/etc/scripts/utils_thirdparty.py @@ -814,7 +814,11 @@ def get_pip_hash(self): return f'--hash=sha256:{self.sha256}' def get_license_keys(self): - return LICENSING.license_keys(self.license_expression, unique=True, simple=True) + try: + keys = LICENSING.license_keys(self.license_expression, unique=True, simple=True) + except license_expression.ExpressionParseError: + return ['unknown'] + return keys def fetch_license_files(self, dest_dir=THIRDPARTY_DIR): """ From 6ab9c10e405b7cff243751082b7a7da4354256a8 Mon Sep 17 00:00:00 2001 From: Philippe Ombredanne Date: Tue, 7 Sep 2021 19:42:08 +0200 Subject: [PATCH 15/30] Update README.rst Signed-off-by: Philippe Ombredanne --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b15be20..deaaa34 100644 --- a/README.rst +++ b/README.rst @@ -129,7 +129,7 @@ https://github.com/nexB/thirdparty-packages Usage after project initialization ---------------------------------- -Once the ``requirements.txt`` and ``requirements-dev.txt`` has been generated +Once the ``requirements.txt`` and ``requirements-dev.txt`` have been generated and the project dependencies and their ABOUT files have been uploaded to thirdparty.aboutcode.org/pypi, you can configure the project without using the ``--init`` option. From bfdc6ff042a5866e67aa4adab4cd5ac71d47285e Mon Sep 17 00:00:00 2001 From: Jono Yang Date: Tue, 7 Sep 2021 12:27:08 -0700 Subject: [PATCH 16/30] Address review comments #33 * Replace references to scancode-toolkit repo with links to the skeleton repo * Remove --python option from configure.bat Signed-off-by: Jono Yang --- configure.bat | 5 ----- etc/scripts/bootstrap.py | 6 +++--- etc/scripts/build_wheels.py | 2 +- etc/scripts/check_thirdparty.py | 2 +- etc/scripts/fetch_requirements.py | 4 ++-- etc/scripts/fix_thirdparty.py | 2 +- etc/scripts/gen_requirements.py | 2 +- etc/scripts/gen_requirements_dev.py | 2 +- etc/scripts/utils_dejacode.py | 2 +- etc/scripts/utils_requirements.py | 2 +- etc/scripts/utils_thirdparty.py | 2 +- 11 files changed, 13 insertions(+), 18 deletions(-) diff --git a/configure.bat b/configure.bat index 31f91c4..0c824a4 100644 --- a/configure.bat +++ b/configure.bat @@ -77,11 +77,6 @@ if not "%1" == "" ( if "%1" EQU "--init" ( set "NO_INDEX= " ) - if "%1" EQU "--python" ( - echo "The --python option is now DEPRECATED. Use the PYTHON_EXECUTABLE environment" - echo "variable instead. Run configure --help for details." - exit /b 0 - ) shift goto again ) diff --git a/etc/scripts/bootstrap.py b/etc/scripts/bootstrap.py index 54701f6..fde505b 100644 --- a/etc/scripts/bootstrap.py +++ b/etc/scripts/bootstrap.py @@ -5,7 +5,7 @@ # ScanCode is a trademark of nexB Inc. # SPDX-License-Identifier: Apache-2.0 # See http://www.apache.org/licenses/LICENSE-2.0 for the license text. -# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://github.com/nexB/skeleton for support or download. # See https://aboutcode.org for more information about nexB OSS projects. # @@ -84,7 +84,7 @@ def bootstrap( OS(s) defaulting to all supported combinations. Create or fetch .ABOUT and .LICENSE files. - Optionally ignore version specifiers and use the ``--latest-version`` + Optionally ignore version specifiers and use the ``--latest-version`` of everything. Sources and wheels are fetched with attempts first from PyPI, then our remote repository. @@ -172,7 +172,7 @@ def bootstrap( (PypiPackage(name, version), envt) for name, version, envt in name_version_envt_to_build ] - + print(f'==> BUILDING #{len(packages_and_envts_to_build)} MISSING WHEELS') package_envts_not_built, wheel_filenames_built = utils_thirdparty.build_missing_wheels( diff --git a/etc/scripts/build_wheels.py b/etc/scripts/build_wheels.py index 416adc7..352b705 100644 --- a/etc/scripts/build_wheels.py +++ b/etc/scripts/build_wheels.py @@ -5,7 +5,7 @@ # ScanCode is a trademark of nexB Inc. # SPDX-License-Identifier: Apache-2.0 # See http://www.apache.org/licenses/LICENSE-2.0 for the license text. -# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://github.com/nexB/skeleton for support or download. # See https://aboutcode.org for more information about nexB OSS projects. # import click diff --git a/etc/scripts/check_thirdparty.py b/etc/scripts/check_thirdparty.py index b29ce2b..e48cfce 100644 --- a/etc/scripts/check_thirdparty.py +++ b/etc/scripts/check_thirdparty.py @@ -5,7 +5,7 @@ # ScanCode is a trademark of nexB Inc. # SPDX-License-Identifier: Apache-2.0 # See http://www.apache.org/licenses/LICENSE-2.0 for the license text. -# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://github.com/nexB/skeleton for support or download. # See https://aboutcode.org for more information about nexB OSS projects. # import click diff --git a/etc/scripts/fetch_requirements.py b/etc/scripts/fetch_requirements.py index dfd202a..21de865 100644 --- a/etc/scripts/fetch_requirements.py +++ b/etc/scripts/fetch_requirements.py @@ -5,7 +5,7 @@ # ScanCode is a trademark of nexB Inc. # SPDX-License-Identifier: Apache-2.0 # See http://www.apache.org/licenses/LICENSE-2.0 for the license text. -# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://github.com/nexB/skeleton for support or download. # See https://aboutcode.org for more information about nexB OSS projects. # import itertools @@ -108,7 +108,7 @@ def fetch_requirements( envs = (utils_thirdparty.Environment.from_pyver_and_os(pyv, os) for pyv, os in envs) for env, reqf in itertools.product(envs, requirements_files): - + for package, error in utils_thirdparty.fetch_wheels( environment=env, requirements_file=reqf, diff --git a/etc/scripts/fix_thirdparty.py b/etc/scripts/fix_thirdparty.py index b74b497..061d3fa 100644 --- a/etc/scripts/fix_thirdparty.py +++ b/etc/scripts/fix_thirdparty.py @@ -5,7 +5,7 @@ # ScanCode is a trademark of nexB Inc. # SPDX-License-Identifier: Apache-2.0 # See http://www.apache.org/licenses/LICENSE-2.0 for the license text. -# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://github.com/nexB/skeleton for support or download. # See https://aboutcode.org for more information about nexB OSS projects. # import click diff --git a/etc/scripts/gen_requirements.py b/etc/scripts/gen_requirements.py index c917c87..3be974c 100644 --- a/etc/scripts/gen_requirements.py +++ b/etc/scripts/gen_requirements.py @@ -5,7 +5,7 @@ # ScanCode is a trademark of nexB Inc. # SPDX-License-Identifier: Apache-2.0 # See http://www.apache.org/licenses/LICENSE-2.0 for the license text. -# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://github.com/nexB/skeleton for support or download. # See https://aboutcode.org for more information about nexB OSS projects. # import click diff --git a/etc/scripts/gen_requirements_dev.py b/etc/scripts/gen_requirements_dev.py index 91e0ce6..ff4ce50 100644 --- a/etc/scripts/gen_requirements_dev.py +++ b/etc/scripts/gen_requirements_dev.py @@ -5,7 +5,7 @@ # ScanCode is a trademark of nexB Inc. # SPDX-License-Identifier: Apache-2.0 # See http://www.apache.org/licenses/LICENSE-2.0 for the license text. -# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://github.com/nexB/skeleton for support or download. # See https://aboutcode.org for more information about nexB OSS projects. # import click diff --git a/etc/scripts/utils_dejacode.py b/etc/scripts/utils_dejacode.py index bb37de1..8b6e5d2 100644 --- a/etc/scripts/utils_dejacode.py +++ b/etc/scripts/utils_dejacode.py @@ -5,7 +5,7 @@ # ScanCode is a trademark of nexB Inc. # SPDX-License-Identifier: Apache-2.0 # See http://www.apache.org/licenses/LICENSE-2.0 for the license text. -# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://github.com/nexB/skeleton for support or download. # See https://aboutcode.org for more information about nexB OSS projects. # import io diff --git a/etc/scripts/utils_requirements.py b/etc/scripts/utils_requirements.py index 8b088ad..ddbed61 100644 --- a/etc/scripts/utils_requirements.py +++ b/etc/scripts/utils_requirements.py @@ -5,7 +5,7 @@ # ScanCode is a trademark of nexB Inc. # SPDX-License-Identifier: Apache-2.0 # See http://www.apache.org/licenses/LICENSE-2.0 for the license text. -# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://github.com/nexB/skeleton for support or download. # See https://aboutcode.org for more information about nexB OSS projects. # import subprocess diff --git a/etc/scripts/utils_thirdparty.py b/etc/scripts/utils_thirdparty.py index 978f0e1..0ebf6b2 100644 --- a/etc/scripts/utils_thirdparty.py +++ b/etc/scripts/utils_thirdparty.py @@ -5,7 +5,7 @@ # ScanCode is a trademark of nexB Inc. # SPDX-License-Identifier: Apache-2.0 # See http://www.apache.org/licenses/LICENSE-2.0 for the license text. -# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://github.com/nexB/skeleton for support or download. # See https://aboutcode.org for more information about nexB OSS projects. # from collections import defaultdict From 71d8dad4444f3de7a66a776e8848b0f1d1b1e201 Mon Sep 17 00:00:00 2001 From: Jono Yang Date: Wed, 8 Sep 2021 12:12:48 -0700 Subject: [PATCH 17/30] Update READMEs Signed-off-by: Jono Yang --- README.rst | 156 +-------------------------------------- docs/skeleton-usage.rst | 157 ++++++++++++++++++++++++++++++++++++++++ etc/scripts/README.rst | 147 +++++++++++++++++++++++++++++++++++++ 3 files changed, 305 insertions(+), 155 deletions(-) create mode 100644 docs/skeleton-usage.rst create mode 100755 etc/scripts/README.rst diff --git a/README.rst b/README.rst index deaaa34..4173689 100644 --- a/README.rst +++ b/README.rst @@ -9,161 +9,7 @@ our existing ones as well. Usage ===== -A brand new project -------------------- -.. code-block:: bash - - git init my-new-repo - cd my-new-repo - git pull git@github.com:nexB/skeleton - - # Create the new repo on GitHub, then update your remote - git remote set-url origin git@github.com:nexB/your-new-repo.git - -From here, you can make the appropriate changes to the files for your specific project. - -Update an existing project ---------------------------- -.. code-block:: bash - - cd my-existing-project - git remote add skeleton git@github.com:nexB/skeleton - git fetch skeleton - git merge skeleton/main --allow-unrelated-histories - -This is also the workflow to use when updating the skeleton files in any given repository. - -Customizing ------------ - -You typically want to perform these customizations: - -- remove or update the src/README.rst and tests/README.rst files -- set project info and dependencies in setup.cfg -- check the configure and configure.bat defaults - -Initializing a project ----------------------- - -All projects using the skeleton will be expected to pull all of it dependencies -from thirdparty.aboutcode.org/pypi or the local thirdparty directory, using -requirements.txt and/or requirements-dev.txt to determine what version of a -package to collect. By default, PyPI will not be used to find and collect -packages from. - -In the case where we are starting a new project where we do not have -requirements.txt and requirements-dev.txt and whose dependencies are not yet on -thirdparty.aboutcode.org/pypi, we run the following command after adding and -customizing the skeleton files to your project: - -.. code-block:: bash - - ./configure --init - -This will initialize the virtual environment for the project, pull in the -dependencies from PyPI and add them to the virtual environment. - -Generating requirements.txt and requirements-dev.txt ----------------------------------------------------- - -After the project has been initialized, we can generate the requirements.txt and -requirements-dev.txt files. - -Ensure the virtual environment is enabled. - -.. code-block:: bash - - source venv/bin/activate - -To generate requirements.txt: - -.. code-block:: bash - - python etc/scripts/gen_requirements.py -s venv/lib/python/site-packages/ - -Replace \ with the version number of the Python being used, for example: ``venv/lib/python3.6/site-packages/`` - -To generate requirements-dev.txt after requirements.txt has been generated: - -.. code-block:: bash - ./configure --init --dev - python etc/scripts/gen_requirements_dev.py -s venv/lib/python/site-packages/ - -Note: on Windows, the ``site-packages`` directory is located at ``venv\Lib\site-packages\`` - -.. code-block:: bash - - python .\\etc\\scripts\\gen_requirements.py -s .\\venv\\Lib\\site-packages\\ - .\configure --init --dev - python .\\etc\\scripts\\gen_requirements_dev.py -s .\\venv\\Lib\\site-packages\\ - -Collecting and generating ABOUT files for dependencies ------------------------------------------------------- - -Ensure that the dependencies used by ``etc/scripts/bootstrap.py`` are installed: - -.. code-block:: bash - - pip install -r etc/scripts/requirements.txt - -Once we have requirements.txt and requirements-dev.txt, we can fetch the project -dependencies as wheels and generate ABOUT files for them: - -.. code-block:: bash - - python etc/scripts/bootstrap.py -r requirements.txt -r requirements-dev.txt --with-deps - -There may be issues with the generated ABOUT files, which will have to be -corrected. You can check to see if your corrections are valid by running: - -.. code-block:: bash - - python etc/scripts/check_thirdparty.py -d thirdparty - -Once the wheels are collected and the ABOUT files are generated and correct, -upload them to thirdparty.aboutcode.org/pypi by placing the wheels and ABOUT -files from the thirdparty directory to the pypi directory at -https://github.com/nexB/thirdparty-packages - - -Usage after project initialization ----------------------------------- - -Once the ``requirements.txt`` and ``requirements-dev.txt`` have been generated -and the project dependencies and their ABOUT files have been uploaded to -thirdparty.aboutcode.org/pypi, you can configure the project without using the -``--init`` option. - -If the virtual env for the project becomes polluted, or you would like to remove -it, use the ``--clean`` option: - -.. code-block:: bash - - ./configure --clean - -Then you can run ``./configure`` again to set up the project virtual environment. - -To set up the project for development use: - -.. code-block:: bash - - ./configure --dev - -To update the project dependencies (adding, removing, updating packages, etc.), -update the dependencies in ``setup.cfg``, then run: - -.. code-block:: bash - - ./configure --clean # Remove existing virtual environment - ./configure --init # Create project virtual environment, pull in new dependencies - source venv/bin/activate # Ensure virtual environment is activated - python etc/scripts/gen_requirements.py -s venv/lib/python/site-packages/ # Regenerate requirements.txt - python etc/scripts/gen_requirements_dev.py -s venv/lib/python/site-packages/ # Regenerate requirements-dev.txt - pip install -r etc/scripts/requirements.txt # Install dependencies needed by etc/scripts/bootstrap.py - python etc/scripts/bootstrap.py -r requirements.txt -r requirements-dev.txt --with-deps # Collect dependency wheels and their ABOUT files - -Ensure that the generated ABOUT files are valid, then take the dependency wheels -and ABOUT files and upload them to thirdparty.aboutcode.org/pypi. +Usage instructions can be found in ``docs/skeleton-usage.rst``. Release Notes ============= diff --git a/docs/skeleton-usage.rst b/docs/skeleton-usage.rst new file mode 100644 index 0000000..7d16259 --- /dev/null +++ b/docs/skeleton-usage.rst @@ -0,0 +1,157 @@ +Usage +===== +A brand new project +------------------- +.. code-block:: bash + + git init my-new-repo + cd my-new-repo + git pull git@github.com:nexB/skeleton + + # Create the new repo on GitHub, then update your remote + git remote set-url origin git@github.com:nexB/your-new-repo.git + +From here, you can make the appropriate changes to the files for your specific project. + +Update an existing project +--------------------------- +.. code-block:: bash + + cd my-existing-project + git remote add skeleton git@github.com:nexB/skeleton + git fetch skeleton + git merge skeleton/main --allow-unrelated-histories + +This is also the workflow to use when updating the skeleton files in any given repository. + +Customizing +----------- + +You typically want to perform these customizations: + +- remove or update the src/README.rst and tests/README.rst files +- set project info and dependencies in setup.cfg +- check the configure and configure.bat defaults + +Initializing a project +---------------------- + +All projects using the skeleton will be expected to pull all of it dependencies +from thirdparty.aboutcode.org/pypi or the local thirdparty directory, using +requirements.txt and/or requirements-dev.txt to determine what version of a +package to collect. By default, PyPI will not be used to find and collect +packages from. + +In the case where we are starting a new project where we do not have +requirements.txt and requirements-dev.txt and whose dependencies are not yet on +thirdparty.aboutcode.org/pypi, we run the following command after adding and +customizing the skeleton files to your project: + +.. code-block:: bash + + ./configure --init + +This will initialize the virtual environment for the project, pull in the +dependencies from PyPI and add them to the virtual environment. + +Generating requirements.txt and requirements-dev.txt +---------------------------------------------------- + +After the project has been initialized, we can generate the requirements.txt and +requirements-dev.txt files. + +Ensure the virtual environment is enabled. + +.. code-block:: bash + + source venv/bin/activate + +To generate requirements.txt: + +.. code-block:: bash + + python etc/scripts/gen_requirements.py -s venv/lib/python/site-packages/ + +Replace \ with the version number of the Python being used, for example: ``venv/lib/python3.6/site-packages/`` + +To generate requirements-dev.txt after requirements.txt has been generated: + +.. code-block:: bash + ./configure --init --dev + python etc/scripts/gen_requirements_dev.py -s venv/lib/python/site-packages/ + +Note: on Windows, the ``site-packages`` directory is located at ``venv\Lib\site-packages\`` + +.. code-block:: bash + + python .\\etc\\scripts\\gen_requirements.py -s .\\venv\\Lib\\site-packages\\ + .\configure --init --dev + python .\\etc\\scripts\\gen_requirements_dev.py -s .\\venv\\Lib\\site-packages\\ + +Collecting and generating ABOUT files for dependencies +------------------------------------------------------ + +Ensure that the dependencies used by ``etc/scripts/bootstrap.py`` are installed: + +.. code-block:: bash + + pip install -r etc/scripts/requirements.txt + +Once we have requirements.txt and requirements-dev.txt, we can fetch the project +dependencies as wheels and generate ABOUT files for them: + +.. code-block:: bash + + python etc/scripts/bootstrap.py -r requirements.txt -r requirements-dev.txt --with-deps + +There may be issues with the generated ABOUT files, which will have to be +corrected. You can check to see if your corrections are valid by running: + +.. code-block:: bash + + python etc/scripts/check_thirdparty.py -d thirdparty + +Once the wheels are collected and the ABOUT files are generated and correct, +upload them to thirdparty.aboutcode.org/pypi by placing the wheels and ABOUT +files from the thirdparty directory to the pypi directory at +https://github.com/nexB/thirdparty-packages + + +Usage after project initialization +---------------------------------- + +Once the ``requirements.txt`` and ``requirements-dev.txt`` have been generated +and the project dependencies and their ABOUT files have been uploaded to +thirdparty.aboutcode.org/pypi, you can configure the project without using the +``--init`` option. + +If the virtual env for the project becomes polluted, or you would like to remove +it, use the ``--clean`` option: + +.. code-block:: bash + + ./configure --clean + +Then you can run ``./configure`` again to set up the project virtual environment. + +To set up the project for development use: + +.. code-block:: bash + + ./configure --dev + +To update the project dependencies (adding, removing, updating packages, etc.), +update the dependencies in ``setup.cfg``, then run: + +.. code-block:: bash + + ./configure --clean # Remove existing virtual environment + ./configure --init # Create project virtual environment, pull in new dependencies + source venv/bin/activate # Ensure virtual environment is activated + python etc/scripts/gen_requirements.py -s venv/lib/python/site-packages/ # Regenerate requirements.txt + python etc/scripts/gen_requirements_dev.py -s venv/lib/python/site-packages/ # Regenerate requirements-dev.txt + pip install -r etc/scripts/requirements.txt # Install dependencies needed by etc/scripts/bootstrap.py + python etc/scripts/bootstrap.py -r requirements.txt -r requirements-dev.txt --with-deps # Collect dependency wheels and their ABOUT files + +Ensure that the generated ABOUT files are valid, then take the dependency wheels +and ABOUT files and upload them to thirdparty.aboutcode.org/pypi. diff --git a/etc/scripts/README.rst b/etc/scripts/README.rst new file mode 100755 index 0000000..4cb6ec7 --- /dev/null +++ b/etc/scripts/README.rst @@ -0,0 +1,147 @@ +This directory contains the tools to: + +- manage a directory of thirdparty Python package source, wheels and metadata: + pin, build, update, document and publish to a PyPI-like repo (GitHub release) + +- build and publish scancode releases as wheel, sources and OS-specific bundles. + + +NOTE: These are tested to run ONLY on Linux. + + +Thirdparty packages management scripts +====================================== + +Pre-requisites +-------------- + +* There are two run "modes": + + * To generate or update pip requirement files, you need to start with a clean + virtualenv as instructed below (This is to avoid injecting requirements + specific to the tools here in the main requirements). + + * For other usages, the tools here can run either in their own isolated + virtualenv best or in the the main configured development virtualenv. + These requireements need to be installed:: + + pip install --requirement etc/release/requirements.txt + +TODO: we need to pin the versions of these tools + + + +Generate or update pip requirement files +---------------------------------------- + +Scripts +~~~~~~~ + +**gen_requirements.py**: create/update requirements files from currently + installed requirements. + +**gen_requirements_dev.py** does the same but can subtract the main requirements + to get extra requirements used in only development. + + +Usage +~~~~~ + +The sequence of commands to run are: + + +* Start with these to generate the main pip requirements file:: + + ./configure --clean + ./configure + python etc/release/gen_requirements.py --site-packages-dir + +* You can optionally install or update extra main requirements after the + ./configure step such that these are included in the generated main requirements. + +* Optionally, generate a development pip requirements file by running these:: + + ./configure --clean + ./configure --dev + python etc/release/gen_requirements_dev.py --site-packages-dir + +* You can optionally install or update extra dev requirements after the + ./configure step such that these are included in the generated dev + requirements. + +Notes: we generate development requirements after the main as this step requires +the main requirements.txt to be up-to-date first. See **gen_requirements.py and +gen_requirements_dev.py** --help for details. + +Note: this does NOT hash requirements for now. + +Note: Be aware that if you are using "conditional" requirements (e.g. only for +OS or Python versions) in setup.py/setp.cfg/requirements.txt as these are NOT +yet supported. + + +Populate a thirdparty directory with wheels, sources, .ABOUT and license files +------------------------------------------------------------------------------ + +Scripts +~~~~~~~ + +* **fetch_requirements.py** will fetch package wheels, their ABOUT, LICENSE and + NOTICE files to populate a local a thirdparty directory strictly from our + remote repo and using only pinned packages listed in one or more pip + requirements file(s). Fetch only requirements for specific python versions and + operating systems. Optionally fetch the corresponding source distributions. + +* **publish_files.py** will upload/sync a thirdparty directory of files to our + remote repo. Requires a GitHub personal access token. + +* **build_wheels.py** will build a package binary wheel for multiple OS and + python versions. Optionally wheels that contain native code are built + remotely. Dependent wheels are optionally included. Requires Azure credentials + and tokens if building wheels remotely on multiple operatin systems. + +* **fix_thirdparty.py** will fix a thirdparty directory with a best effort to + add missing wheels, sources archives, create or fetch or fix .ABOUT, .NOTICE + and .LICENSE files. Requires Azure credentials and tokens if requesting the + build of missing wheels remotely on multiple operatin systems. + +* **check_thirdparty.py** will check a thirdparty directory for errors. + +* **bootstrap.py** will bootstrap a thirdparty directory from a requirements + file(s) to add or build missing wheels, sources archives and create .ABOUT, + .NOTICE and .LICENSE files. Requires Azure credentials and tokens if + requesting the build of missing wheels remotely on multiple operatin systems. + + + +Usage +~~~~~ + +See each command line --help option for details. + +* (TODO) **add_package.py** will add or update a Python package including wheels, + sources and ABOUT files and this for multiple Python version and OSes(for use + with upload_packages.py afterwards) You will need an Azure personal access + token for buidling binaries and an optional DejaCode API key to post and fetch + new package versions there. TODO: explain how we use romp + + +Upgrade virtualenv app +---------------------- + +The bundled virtualenv.pyz has to be upgraded by hand and is stored under +etc/thirdparty + +* Fetch https://github.com/pypa/get-virtualenv/raw//public/virtualenv.pyz + for instance https://github.com/pypa/get-virtualenv/raw/20.2.2/public/virtualenv.pyz + and save to thirdparty and update the ABOUT and LICENSE files as needed. + +* This virtualenv app contains also bundled pip, wheel and setuptools that are + essential for the installation to work. + + +Other files +=========== + +The other files and scripts are test, support and utility modules used by the +main scripts documented here. From d2bafb9f48995d5d2ea8bded503e30a6c25b2ef7 Mon Sep 17 00:00:00 2001 From: Chin Yeung Li Date: Wed, 15 Sep 2021 15:58:48 +0800 Subject: [PATCH 18/30] Fixed #41 - Handled encoding issue when generating ABOUT files Signed-off-by: Chin Yeung Li --- etc/scripts/utils_thirdparty.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/etc/scripts/utils_thirdparty.py b/etc/scripts/utils_thirdparty.py index 0ebf6b2..d77afc3 100644 --- a/etc/scripts/utils_thirdparty.py +++ b/etc/scripts/utils_thirdparty.py @@ -693,7 +693,8 @@ def save_if_modified(location, content): return False if TRACE: print(f'Saving ABOUT (and NOTICE) files for: {self}') - with open(location, 'w') as fo: + wmode = 'wb' if isinstance(content, bytes) else 'w' + with open(location, wmode, encoding="utf-8") as fo: fo.write(content) return True @@ -725,6 +726,8 @@ def load_about_data(self, about_filename_or_data=None, dest_dir=THIRDPARTY_DIR): if os.path.exists(about_path): with open(about_path) as fi: about_data = saneyaml.load(fi.read()) + if not about_data: + return False else: return False else: @@ -1842,7 +1845,7 @@ def get(self, path_or_url, as_text=True): if not os.path.exists(cached): content = get_file_content(path_or_url=path_or_url, as_text=as_text) wmode = 'w' if as_text else 'wb' - with open(cached, wmode) as fo: + with open(cached, wmode, encoding="utf-8") as fo: fo.write(content) return content else: @@ -1854,7 +1857,7 @@ def put(self, filename, content): """ cached = os.path.join(self.directory, filename) wmode = 'wb' if isinstance(content, bytes) else 'w' - with open(cached, wmode) as fo: + with open(cached, wmode, encoding="utf-8") as fo: fo.write(content) @@ -2362,7 +2365,7 @@ def update_requirements(name, version=None, requirements_file='requirements.txt' updated_name_versions = sorted(updated_name_versions) nvs = '\n'.join(f'{name}=={version}' for name, version in updated_name_versions) - with open(requirements_file, 'w') as fo: + with open(requirements_file, 'w', encoding="utf-8") as fo: fo.write(nvs) @@ -2380,7 +2383,7 @@ def hash_requirements(dest_dir=THIRDPARTY_DIR, requirements_file='requirements.t raise Exception(f'Missing required package {name}=={version}') hashed.append(package.specifier_with_hashes) - with open(requirements_file, 'w') as fo: + with open(requirements_file, 'w', encoding="utf-8") as fo: fo.write('\n'.join(hashed)) ################################################################################ From 567156396f81d533ee3d4085fe2030d58b8ebd2f Mon Sep 17 00:00:00 2001 From: Philippe Ombredanne Date: Tue, 5 Oct 2021 12:48:28 +0200 Subject: [PATCH 19/30] Treat text files as text And not a possible binaries Also Ensure that we craft a minimally parsable license expression, even if not correct. Signed-off-by: Philippe Ombredanne --- etc/scripts/utils_thirdparty.py | 51 +++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/etc/scripts/utils_thirdparty.py b/etc/scripts/utils_thirdparty.py index d77afc3..7613a0c 100644 --- a/etc/scripts/utils_thirdparty.py +++ b/etc/scripts/utils_thirdparty.py @@ -24,13 +24,14 @@ import attr import license_expression import packageurl -import utils_pip_compatibility_tags -import utils_pypi_supported_tags import requests import saneyaml +import utils_pip_compatibility_tags +import utils_pypi_supported_tags from commoncode import fileutils from commoncode.hash import multi_checksums +from commoncode.text import python_safe_name from packaging import tags as packaging_tags from packaging import version as packaging_version from utils_requirements import load_requirements @@ -172,11 +173,20 @@ def fetch_wheels( else: force_pinned = False - rrp = list(get_required_remote_packages( - requirements_file=requirements_file, - force_pinned=force_pinned, - remote_links_url=remote_links_url, - )) + try: + rrp = list(get_required_remote_packages( + requirements_file=requirements_file, + force_pinned=force_pinned, + remote_links_url=remote_links_url, + )) + except Exception as e: + raise Exception( + dict( + requirements_file=requirements_file, + force_pinned=force_pinned, + remote_links_url=remote_links_url, + ) + ) from e fetched_filenames = set() for name, version, package in rrp: @@ -211,6 +221,7 @@ def fetch_wheels( print(f'Missed package {nv} in remote repo, has only:') for pv in rr.get_versions(n): print(' ', pv) + raise Exception('Missed some packages in remote repo') def fetch_sources( @@ -261,6 +272,8 @@ def fetch_sources( fetched = package.fetch_sdist(dest_dir=dest_dir) error = f'Failed to fetch' if not fetched else None yield package, error + if missed: + raise Exception(f'Missing source packages in {remote_links_url}', missed) ################################################################################ # @@ -693,8 +706,7 @@ def save_if_modified(location, content): return False if TRACE: print(f'Saving ABOUT (and NOTICE) files for: {self}') - wmode = 'wb' if isinstance(content, bytes) else 'w' - with open(location, wmode, encoding="utf-8") as fo: + with open(location, 'w') as fo: fo.write(content) return True @@ -905,8 +917,8 @@ def load_pkginfo_data(self, dest_dir=THIRDPARTY_DIR): other_classifiers = [c for c in classifiers if not c.startswith('License')] holder = raw_data['Author'] - holder_contact=raw_data['Author-email'] - copyright = f'Copyright (c) {holder} <{holder_contact}>' + holder_contact = raw_data['Author-email'] + copyright_statement = f'Copyright (c) {holder} <{holder_contact}>' pkginfo_data = dict( name=raw_data['Name'], @@ -914,7 +926,7 @@ def load_pkginfo_data(self, dest_dir=THIRDPARTY_DIR): version=raw_data['Version'], description=raw_data['Summary'], homepage_url=raw_data['Home-page'], - copyright=copyright, + copyright=copyright_statement, license_expression=license_expression, holder=holder, holder_contact=holder_contact, @@ -1845,7 +1857,7 @@ def get(self, path_or_url, as_text=True): if not os.path.exists(cached): content = get_file_content(path_or_url=path_or_url, as_text=as_text) wmode = 'w' if as_text else 'wb' - with open(cached, wmode, encoding="utf-8") as fo: + with open(cached, wmode) as fo: fo.write(content) return content else: @@ -1857,7 +1869,7 @@ def put(self, filename, content): """ cached = os.path.join(self.directory, filename) wmode = 'wb' if isinstance(content, bytes) else 'w' - with open(cached, wmode, encoding="utf-8") as fo: + with open(cached, wmode) as fo: fo.write(content) @@ -2331,7 +2343,7 @@ def get_required_remote_packages( repo = get_remote_repo(remote_links_url=remote_links_url) else: # a local path - assert os.path.exists(remote_links_url) + assert os.path.exists(remote_links_url), f'Path does not exist: {remote_links_url}' repo = get_local_repo(directory=remote_links_url) for name, version in required_name_versions: @@ -2365,7 +2377,7 @@ def update_requirements(name, version=None, requirements_file='requirements.txt' updated_name_versions = sorted(updated_name_versions) nvs = '\n'.join(f'{name}=={version}' for name, version in updated_name_versions) - with open(requirements_file, 'w', encoding="utf-8") as fo: + with open(requirements_file, 'w') as fo: fo.write(nvs) @@ -2383,7 +2395,7 @@ def hash_requirements(dest_dir=THIRDPARTY_DIR, requirements_file='requirements.t raise Exception(f'Missing required package {name}=={version}') hashed.append(package.specifier_with_hashes) - with open(requirements_file, 'w', encoding="utf-8") as fo: + with open(requirements_file, 'w') as fo: fo.write('\n'.join(hashed)) ################################################################################ @@ -2961,5 +2973,6 @@ def compute_normalized_license_expression(declared_licenses): from packagedcode import pypi return pypi.compute_normalized_license(declared_licenses) except ImportError: - # Scancode is not installed, we join all license strings and return it - return ' '.join(declared_licenses) + # Scancode is not installed, clean and join all the licenses + lics = [python_safe_name(l).lower() for l in declared_licenses] + return ' AND '.join(lics).lower() From 14f6a2da068bcaa9eed1809bfdafd0656afb47d0 Mon Sep 17 00:00:00 2001 From: Philippe Ombredanne Date: Tue, 5 Oct 2021 12:50:40 +0200 Subject: [PATCH 20/30] Add helper to publish files in GH releases The upload is otherwise shaky. Signed-off-by: Philippe Ombredanne --- etc/scripts/publish_files.py | 204 +++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 etc/scripts/publish_files.py diff --git a/etc/scripts/publish_files.py b/etc/scripts/publish_files.py new file mode 100644 index 0000000..f343cb3 --- /dev/null +++ b/etc/scripts/publish_files.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# ScanCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# +import hashlib +import os +import sys + +from pathlib import Path + +import click +import requests +import utils_thirdparty + +from github_release_retry import github_release_retry as grr + +""" +Create GitHub releases and upload files there. +""" + + +def get_files(location): + """ + Return an iterable of (filename, Path, md5) tuples for files in the `location` + directory tree recursively. + """ + for top, _dirs, files in os.walk(location): + for filename in files: + pth = Path(os.path.join(top, filename)) + with open(pth, 'rb') as fi: + md5 = hashlib.md5(fi.read()).hexdigest() + yield filename, pth, md5 + + +def get_etag_md5(url): + """ + Return the cleaned etag of URL `url` or None. + """ + headers = utils_thirdparty.get_remote_headers(url) + headers = {k.lower(): v for k, v in headers.items()} + etag = headers .get('etag') + if etag: + etag = etag.strip('"').lower() + return etag + + +def create_or_update_release_and_upload_directory( + user, + repo, + tag_name, + token, + directory, + retry_limit=10, + description=None, +): + """ + Create or update a GitHub release at https://github.com// for + `tag_name` tag using the optional `description` for this release. + Use the provided `token` as a GitHub token for API calls authentication. + Upload all files found in the `directory` tree to that GitHub release. + Retry API calls up to `retry_limit` time to work around instability the + GitHub API. + + Remote files that are not the same as the local files are deleted and re- + uploaded. + """ + release_homepage_url = f'https://github.com/{user}/{repo}/releases/{tag_name}' + + # scrape release page HTML for links + urls_by_filename = {os.path.basename(l): l + for l in utils_thirdparty.get_paths_or_urls(links_url=release_homepage_url) + } + + # compute what is new, modified or unchanged + print(f'Compute which files is new, modified or unchanged in {release_homepage_url}') + + new_to_upload = [] + unchanged_to_skip = [] + modified_to_delete_and_reupload = [] + for filename, pth, md5 in get_files(directory): + url = urls_by_filename.get(filename) + if not url: + print(f'{filename} content is NEW, will upload') + new_to_upload.append(pth) + continue + + out_of_date = get_etag_md5(url) != md5 + if out_of_date: + print(f'{url} content is CHANGED based on md5 etag, will re-upload') + modified_to_delete_and_reupload.append(pth) + else: + # print(f'{url} content is IDENTICAL, skipping upload based on Etag') + unchanged_to_skip.append(pth) + print('.') + + ghapi = grr.GithubApi( + github_api_url='https://api.github.com', + user=user, + repo=repo, + token=token, + retry_limit=retry_limit, + ) + + # yank modified + print( + f'Unpublishing {len(modified_to_delete_and_reupload)} published but ' + f'locally modified files in {release_homepage_url}') + + release = ghapi.get_release_by_tag(tag_name) + + for pth in modified_to_delete_and_reupload: + filename = os.path.basename(pth) + asset_id = ghapi.find_asset_id_by_file_name(filename, release) + print (f' Unpublishing file: {filename}).') + response = ghapi.delete_asset(asset_id) + if response.status_code != requests.codes.no_content: # NOQA + raise Exception(f'failed asset deletion: {response}') + + # finally upload new and modified + to_upload = new_to_upload + modified_to_delete_and_reupload + print(f'Publishing with {len(to_upload)} files to {release_homepage_url}') + release = grr.Release(tag_name=tag_name, body=description) + grr.make_release(ghapi, release, to_upload) + + +TOKEN_HELP = ( + 'The Github personal acess token is used to authenticate API calls. ' + 'Required unless you set the GITHUB_TOKEN environment variable as an alternative. ' + 'See for details: https://github.com/settings/tokens and ' + 'https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token' +) + + +@click.command() + +@click.option( + '--user-repo-tag', + help='The GitHub qualified repository user/name/tag in which ' + 'to create the release such as in nexB/thirdparty/pypi', + type=str, + required=True, +) +@click.option( + '-d', '--directory', + help='The directory that contains files to upload to the release.', + type=click.Path(exists=True, readable=True, path_type=str, file_okay=False, resolve_path=True), + required=True, +) +@click.option( + '--token', + help=TOKEN_HELP, + default=os.environ.get('GITHUB_TOKEN', None), + type=str, + required=False, +) +@click.option( + '--description', + help='Text description for the release. Ignored if the release exists.', + default=None, + type=str, + required=False, +) +@click.option( + '--retry_limit', + help='Number of retries when making failing GitHub API calls. ' + 'Retrying helps work around transient failures of the GitHub API.', + type=int, + default=10, +) +@click.help_option('-h', '--help') +def publish_files( + user_repo_tag, + directory, + retry_limit=10, token=None, description=None, +): + """ + Publish all the files in DIRECTORY as assets to a GitHub release. + Either create or update/replace remote files' + """ + if not token: + click.secho('--token required option is missing.') + click.secho(TOKEN_HELP) + sys.exit(1) + + user, repo, tag_name = user_repo_tag.split('/') + + create_or_update_release_and_upload_directory( + user=user, + repo=repo, + tag_name=tag_name, + description=description, + retry_limit=retry_limit, + token=token, + directory=directory, + ) + + +if __name__ == '__main__': + publish_files() From 7aa7d4c08977128a24f029bea1d587f48842210d Mon Sep 17 00:00:00 2001 From: Philippe Ombredanne Date: Fri, 8 Oct 2021 14:39:38 +0200 Subject: [PATCH 21/30] Do not issue warning if thirdparty dir is missing Signed-off-by: Philippe Ombredanne --- configure | 5 ++++- configure.bat | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/configure b/configure index b965692..13ee98e 100755 --- a/configure +++ b/configure @@ -51,7 +51,10 @@ CFG_ROOT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" CFG_BIN_DIR=$CFG_ROOT_DIR/$VIRTUALENV_DIR/bin # Find packages from the local thirdparty directory or from thirdparty.aboutcode.org -PIP_EXTRA_ARGS="--find-links $CFG_ROOT_DIR/thirdparty --find-links https://thirdparty.aboutcode.org/pypi" +if [ -f "$CFG_ROOT_DIR/thirdparty" ]; then + PIP_EXTRA_ARGS="--find-links $CFG_ROOT_DIR/thirdparty " +fi +PIP_EXTRA_ARGS="$PIP_EXTRA_ARGS --find-links https://thirdparty.aboutcode.org/pypi" ################################ diff --git a/configure.bat b/configure.bat index 0c824a4..46ed4b3 100644 --- a/configure.bat +++ b/configure.bat @@ -49,7 +49,11 @@ set "CFG_BIN_DIR=%CFG_ROOT_DIR%\%VIRTUALENV_DIR%\Scripts" @rem ################################ @rem # Thirdparty package locations and index handling -set "PIP_EXTRA_ARGS=--find-links %CFG_ROOT_DIR%\thirdparty --find-links https://thirdparty.aboutcode.org/pypi" & %INDEX_ARG% +if exist ""%CFG_ROOT_DIR%\thirdparty"" ( + set "PIP_EXTRA_ARGS=--find-links %CFG_ROOT_DIR%\thirdparty " +) + +set "PIP_EXTRA_ARGS=%PIP_EXTRA_ARGS% --find-links https://thirdparty.aboutcode.org/pypi" & %INDEX_ARG% @rem ################################ From 7f007db8cab5f7b60df50b160665c31bee6ea84e Mon Sep 17 00:00:00 2001 From: Philippe Ombredanne Date: Fri, 8 Oct 2021 17:25:27 +0200 Subject: [PATCH 22/30] Add failing tests These archive should not crash extraction when using --replace-originals Reported-by: Smascer @Smascer Reported-by: Bryan Sutula @sutula Reference: https://github.com/nexB/extractcode/issues/31 Reference: https://github.com/nexB/scancode-toolkit/issues/2723 Signed-off-by: Philippe Ombredanne --- .../data/extract/replace-originals/issue6550.gz | Bin 0 -> 65536 bytes .../replace-originals/issue6550.gz.ABOUT | 1 + tests/data/extract/replace-originals/rake.1.gz | Bin 0 -> 1370 bytes .../extract/replace-originals/rake.1.gz.ABOUT | 1 + tests/test_extract.py | 16 ++++++++++++++++ 5 files changed, 18 insertions(+) create mode 100644 tests/data/extract/replace-originals/issue6550.gz create mode 100644 tests/data/extract/replace-originals/issue6550.gz.ABOUT create mode 100644 tests/data/extract/replace-originals/rake.1.gz create mode 100644 tests/data/extract/replace-originals/rake.1.gz.ABOUT diff --git a/tests/data/extract/replace-originals/issue6550.gz b/tests/data/extract/replace-originals/issue6550.gz new file mode 100644 index 0000000000000000000000000000000000000000..82b4af102632c3f97bd4957e71ea519751ea0ea8 GIT binary patch literal 65536 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

EX1}=qzy8wF0O3Y(SaC~(L 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 literal 0 HcmV?d00001 diff --git a/tests/data/cli/replace-originals/issue6550.gz.ABOUT b/tests/data/cli/replace-originals/issue6550.gz.ABOUT new file mode 100644 index 0000000..8877c26 --- /dev/null +++ b/tests/data/cli/replace-originals/issue6550.gz.ABOUT @@ -0,0 +1 @@ +download_url: https://golang.org/src/compress/gzip/testdata/issue6550.gz.base64 diff --git a/tests/data/cli/replace-originals/rake.1.gz b/tests/data/cli/replace-originals/rake.1.gz new file mode 100644 index 0000000000000000000000000000000000000000..8cf443b3426b0457719a9c3a50299942161be668 GIT binary patch literal 1370 zcmV-g1*Q5QiwFph;1ogv19D+&WiBxQomSg!+eQ$5=T{8!5~-EwxImG@NZdHF0;}~! zkmNQ&-G?PPG&iPpnY~zHzrJUeq$~@0C~6qCyfb@d=FFK{r*|*tKYd+rJE7Ut*@x8$ zO@Ek@GP*3orz8c$M`^61Mi{YS$7Vv;XIHZc*-HDmpo~e>HRYnQ(R;i*iYW870 zzq#JOaXkHHLL1TIsuiLO4XPSn(sm8Ge*js(aWX zek1NV4OhFO^oA*tvJtt_c0#2NcS<&_az2Spsj3D-3u#4GGgWPa0Ag2KTD5}Uq9Be~x)ajms`tg`})3`Reb~yyLdQzvh;arw7-%htx@Vl`hS>3cHqa5<;HNH>95OMfS* zBs!kYRv(u0MNs76OO>#(@1+hZ3o0mCIB(dVB*_o7gbzqzOQYca(h?M7flIoitHC&q zI?Z)R*m2!bfpf(Lcu9z?LwAHJXz8<-Y^$93mJ(fS`gCowSmG#roE$)47m$*pPa}DL<_Qw$obcC$ot8v%i$+VnY$PrlMeMc zBQx*7Netf)y3t=S{TOq6hcdx`r$WZ{#bAv!7ThpI?i+9)J6C8@+EJ_m><1@6L=z%H zkq(Ok@!;<;6YNleb-W1~r;9}t=bAr}k2GN;g=-t#H4>_IaUx>2K_GYK`!`#}2rJ|C zu3UtXix_w1{JDWGHX^7*Vqrr>_N02$1rpQHa)}PbftAQm<`{#B1IaaE6+S{hY1HdY zlqz(+UL@0LVcMzjYJYi;rfQs=YqT9k3dWFbkOqI_+`AxW5w|Of?ocYOn}a7{G`#?d zv`q~T;(m-y8@9IFgz;i6vqGA;m-F}M^W|`HK2GK;uRTiZ4N^t6EDMuYK|oc%FJqSq z5L`f6yL6Q9J@?bOjzxv$?qINMAps!dP8$hM184{|4CnyZ>DoT8Z?Rt=_hS$`(1{9N z0^}sv6qv&p_F%{vvN<5@>n^)m-w!NxBQgv+I?(tXis@5)`*D6b8&Jw|g88I))UP0O zP)g6)+pC-N+y3HcYR1X3SA2h-aT!GW)bT?ci_gj(RAI--rNpT8MNY71u}5mF;|hQl zEq7VHL`MhW+m~QXX%AIz067Hc_&f-({-2yX6UO5J%#D+^H!93>n4||j>dFN!+)?yo z_kwx+N0N#BA>s_6KJ@4iw0g?W?<*a1oI#e)ogcy1Q@g*@Z5mwUv&3NPAhMrW6+o8+_hafRYFDR%G6u>Nu%nh^hg2oFt)={xe)9N7xvas>O4 z8J@_q+x5lGvU^&$klWTsY<8^XmVT2P`je%Rn6o!uHTpCe#`!k$%K718 cpixj|znYOw5kTV8VZrjh-^fVx Date: Fri, 8 Oct 2021 23:13:37 +0200 Subject: [PATCH 24/30] Filter event errors in replace_originals block This is cleaner than to do tis in the main loop. Also refine and format the documentation. Reported-by: Bryan Sutula @sutula Reference: https://github.com/nexB/extractcode/issues/31 Reference: https://github.com/nexB/scancode-toolkit/issues/2723 Signed-off-by: Philippe Ombredanne --- src/extractcode/extract.py | 41 ++++++++++++++++---------------------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/src/extractcode/extract.py b/src/extractcode/extract.py index 8778951..8c547f2 100644 --- a/src/extractcode/extract.py +++ b/src/extractcode/extract.py @@ -88,31 +88,32 @@ def extract( ignore_pattern=(), ): """ - Walk and extract any archives found at `location` (either a file or - directory). Extract only archives of a kind listed in the `kinds` kind tuple. + Walk and extract any archives found at ``location`` (either a file or + directory). Extract only archives of a kind listed in the ``kinds`` kind + tuple. - Return an iterable of ExtractEvent tuples for each extracted archive. This - can be used to track extraction progress: + Return an iterable of ExtractEvent for each extracted archive. This can be + used to track extraction progress: - one event is emitted just before extracting an archive. The ExtractEvent - warnings and errors are empty. The `done` flag is False. + warnings and errors are empty. The "done" flag is False. - one event is emitted right after extracting an archive. The ExtractEvent - warnings and errors contains warnings and errors if any. The `done` flag + warnings and errors contains warnings and errors if any. The "done" flag is True. - If `recurse` is True, extract recursively archives nested inside other - archives. If `recurse` is false, then do not extract further an already + If ``recurse`` is True, extract recursively archives nested inside other + archives. If ``recurse`` is false, then do not extract further an already extracted archive identified by the corresponding extract suffix location. - If `replace_originals` is True, the extracted archives are replaced by the - extracted content. + If ``replace_originals`` is True, the extracted archives are replaced by the + extracted content, only if the extraction was successful. ``ignore_pattern`` is a list of glob patterns to ignore. Note that while the original filesystem is walked top-down, breadth-first, - if ``recurse`` and a nested archive is found, it is extracted at full depth - first before resuming the filesystem walk. + if ``recurse`` and a nested archive is found, it is extracted first + recursively and at full depth-first before resuming the filesystem walk. """ extract_events = extract_files( @@ -126,27 +127,19 @@ def extract( processed_events_append = processed_events.append for event in extract_events: yield event - if event.warnings or event.errors: - if TRACE: - logger.debug( - f'extract:replace_originals: {event} has errors. ' - 'not replacing originals' - ) - continue if replace_originals: processed_events_append(event) - - # move files around when done + # move files around when done, unless there are errors if replace_originals: for xevent in reversed(processed_events): - if xevent.done: + if xevent.done and not (event.warnings or event.errors): source = xevent.source target = xevent.target if TRACE: logger.debug( - 'extract:replace_originals: replace ' - '%(source)r by %(target)r' % locals() + f'extract:replace_originals: replacing ' + f'{source!r} by {target!r}' ) fileutils.delete(source) fileutils.copytree(target, source) From be6894d425b1054cf35a8c8c3797409e8ebaa1a5 Mon Sep 17 00:00:00 2001 From: Philippe Ombredanne Date: Sat, 9 Oct 2021 00:07:07 +0200 Subject: [PATCH 25/30] Update CHANGELOG in prep for release Signed-off-by: Philippe Ombredanne --- CHANGELOG.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 01b9f05..efd6472 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,22 @@ Changelog ========= +v30.0.0 (next) +-------- + +- Update to the latest skeleton. The virtualenv is now created under the venv + directory with the ./configure --dev +- Switch back to semver versioning. Calver was not really needed here. +- Do not crash if there is a corrupted archive with the --replace-originals + option + +Thank you to: + +- Jono Yang @JonoYang +- Bryan Sutula @sutula +- Smascer @Smascer + + v21.7.23 -------- From 1a22e574070fcc8aa15e421ea51d98aab3f20616 Mon Sep 17 00:00:00 2001 From: Philippe Ombredanne Date: Sat, 9 Oct 2021 00:07:21 +0200 Subject: [PATCH 26/30] Generate requirements Signed-off-by: Philippe Ombredanne --- requirements-dev.txt | 9 +++++++++ requirements.txt | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index e69de29..4dcff74 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -0,0 +1,9 @@ +execnet==1.9.0 +iniconfig==1.1.1 +packaging==21.0 +py==1.10.0 +pyparsing==2.4.7 +pytest==6.2.5 +pytest-forked==1.3.0 +pytest-xdist==2.4.0 +toml==0.10.2 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e69de29..c5d3aad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,36 @@ +attrs==21.2.0 +beautifulsoup4==4.10.0 +binaryornot==0.4.4 +certifi==2021.5.30 +cffi==1.14.6 +chardet==4.0.0 +charset-normalizer==2.0.6 +click==8.0.1 +commoncode==30.0.0 +cryptography==35.0.0 +extractcode-7z==16.5.210531 +extractcode-libarchive==3.5.1.210531 +idna==3.2 +importlib-metadata==4.8.1 +intbitset==2.4.1 +patch==1.16 +pdfminer.six==20201018 +pip==21.2.4 +pluggy==1.0.0 +plugincode==21.1.21 +pycparser==2.20 +PyYAML==5.4.1 +requests==2.26.0 +saneyaml==0.5.2 +setuptools==58.1.0 +six==1.16.0 +sortedcontainers==2.4.0 +soupsieve==2.2.1 +text-unidecode==1.3 +typecode==21.6.1 +typecode-libmagic==5.39.210531 +typing==3.6.6 +typing-extensions==3.10.0.2 +urllib3==1.26.7 +wheel==0.37.0 +zipp==3.6.0 From cec17e76bbf39b2a824bd12704ee8cfc1c9e0312 Mon Sep 17 00:00:00 2001 From: Philippe Ombredanne Date: Sat, 9 Oct 2021 00:07:56 +0200 Subject: [PATCH 27/30] Remove used empty skeleton directory Signed-off-by: Philippe Ombredanne --- thirdparty/README.rst | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 thirdparty/README.rst diff --git a/thirdparty/README.rst b/thirdparty/README.rst deleted file mode 100644 index b31482f..0000000 --- a/thirdparty/README.rst +++ /dev/null @@ -1,2 +0,0 @@ -Put your Python dependency wheels to be vendored in this directory. - From 6b71c998c0cedb76bd7f1b14cb31cd1f71d353f2 Mon Sep 17 00:00:00 2001 From: Philippe Ombredanne Date: Sat, 9 Oct 2021 00:09:16 +0200 Subject: [PATCH 28/30] Streamline test code THere was a dangling JSON file loaded for no reason Signed-off-by: Philippe Ombredanne --- tests/test_archive.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_archive.py b/tests/test_archive.py index a886545..64f17ce 100644 --- a/tests/test_archive.py +++ b/tests/test_archive.py @@ -393,10 +393,7 @@ def test_extract_targz_with_mixed_case_and_symlink(self): test_dir = self.get_temp_dir() result = archive.extract_tar(test_file, test_dir) assert [] == result - import json exp_file = self.get_test_loc('archive/tgz/mixed_case_and_symlink.tgz.expected.json') - with open(exp_file) as ef: - expected_files = json.load(ef) check_files(test_dir, exp_file, regen=False) def test_extract_targz_symlinks(self): From c587f9cd8979795506630c0473c1b8ddff602226 Mon Sep 17 00:00:00 2001 From: Philippe Ombredanne Date: Sat, 9 Oct 2021 00:11:25 +0200 Subject: [PATCH 29/30] Ue new venv dir for CLI tests The new skeleton now use venv and not tmp Signed-off-by: Philippe Ombredanne --- tests/test_extractcode_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_extractcode_cli.py b/tests/test_extractcode_cli.py index cd05c8d..aa9864f 100644 --- a/tests/test_extractcode_cli.py +++ b/tests/test_extractcode_cli.py @@ -34,7 +34,7 @@ def run_extract(options, expected_rc=None, cwd=None): bin_dir = 'Scripts' if on_windows else 'bin' # note: this assumes that we are using a standard directory layout as set # with the configure script - cmd_loc = os.path.join(project_root, 'tmp', bin_dir, 'extractcode') + cmd_loc = os.path.join(project_root, 'venv', bin_dir, 'extractcode') assert os.path.exists(cmd_loc + ('.exe' if on_windows else '')) args = [cmd_loc] + options result = subprocess.run(args, From c6866cc82118d00f3d9a3dceac94e564df69c873 Mon Sep 17 00:00:00 2001 From: Philippe Ombredanne Date: Sat, 9 Oct 2021 00:12:22 +0200 Subject: [PATCH 30/30] Update CHANGELOG Signed-off-by: Philippe Ombredanne --- CHANGELOG.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index efd6472..e441787 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,7 +15,7 @@ Thank you to: - Jono Yang @JonoYang - Bryan Sutula @sutula - Smascer @Smascer - +- Chin-Yeung Li @chinyeungli v21.7.23 --------

EX1}=qzy8wF0O3Y(SaC~(L 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 literal 0 HcmV?d00001 diff --git a/tests/data/extract/replace-originals/issue6550.gz.ABOUT b/tests/data/extract/replace-originals/issue6550.gz.ABOUT new file mode 100644 index 0000000..8877c26 --- /dev/null +++ b/tests/data/extract/replace-originals/issue6550.gz.ABOUT @@ -0,0 +1 @@ +download_url: https://golang.org/src/compress/gzip/testdata/issue6550.gz.base64 diff --git a/tests/data/extract/replace-originals/rake.1.gz b/tests/data/extract/replace-originals/rake.1.gz new file mode 100644 index 0000000000000000000000000000000000000000..8cf443b3426b0457719a9c3a50299942161be668 GIT binary patch literal 1370 zcmV-g1*Q5QiwFph;1ogv19D+&WiBxQomSg!+eQ$5=T{8!5~-EwxImG@NZdHF0;}~! zkmNQ&-G?PPG&iPpnY~zHzrJUeq$~@0C~6qCyfb@d=FFK{r*|*tKYd+rJE7Ut*@x8$ zO@Ek@GP*3orz8c$M`^61Mi{YS$7Vv;XIHZc*-HDmpo~e>HRYnQ(R;i*iYW870 zzq#JOaXkHHLL1TIsuiLO4XPSn(sm8Ge*js(aWX zek1NV4OhFO^oA*tvJtt_c0#2NcS<&_az2Spsj3D-3u#4GGgWPa0Ag2KTD5}Uq9Be~x)ajms`tg`})3`Reb~yyLdQzvh;arw7-%htx@Vl`hS>3cHqa5<;HNH>95OMfS* zBs!kYRv(u0MNs76OO>#(@1+hZ3o0mCIB(dVB*_o7gbzqzOQYca(h?M7flIoitHC&q zI?Z)R*m2!bfpf(Lcu9z?LwAHJXz8<-Y^$93mJ(fS`gCowSmG#roE$)47m$*pPa}DL<_Qw$obcC$ot8v%i$+VnY$PrlMeMc zBQx*7Netf)y3t=S{TOq6hcdx`r$WZ{#bAv!7ThpI?i+9)J6C8@+EJ_m><1@6L=z%H zkq(Ok@!;<;6YNleb-W1~r;9}t=bAr}k2GN;g=-t#H4>_IaUx>2K_GYK`!`#}2rJ|C zu3UtXix_w1{JDWGHX^7*Vqrr>_N02$1rpQHa)}PbftAQm<`{#B1IaaE6+S{hY1HdY zlqz(+UL@0LVcMzjYJYi;rfQs=YqT9k3dWFbkOqI_+`AxW5w|Of?ocYOn}a7{G`#?d zv`q~T;(m-y8@9IFgz;i6vqGA;m-F}M^W|`HK2GK;uRTiZ4N^t6EDMuYK|oc%FJqSq z5L`f6yL6Q9J@?bOjzxv$?qINMAps!dP8$hM184{|4CnyZ>DoT8Z?Rt=_hS$`(1{9N z0^}sv6qv&p_F%{vvN<5@>n^)m-w!NxBQgv+I?(tXis@5)`*D6b8&Jw|g88I))UP0O zP)g6)+pC-N+y3HcYR1X3SA2h-aT!GW)bT?ci_gj(RAI--rNpT8MNY71u}5mF;|hQl zEq7VHL`MhW+m~QXX%AIz067Hc_&f-({-2yX6UO5J%#D+^H!93>n4||j>dFN!+)?yo z_kwx+N0N#BA>s_6KJ@4iw0g?W?<*a1oI#e)ogcy1Q@g*@Z5mwUv&3NPAhMrW6+o8+_hafRYFDR%G6u>Nu%nh^hg2oFt)={xe)9N7xvas>O4 z8J@_q+x5lGvU^&$klWTsY<8^XmVT2P`je%Rn6o!uHTpCe#`!k$%K718 cpixj|znYOw5kTV8VZrjh-^fVx Date: Fri, 8 Oct 2021 17:55:46 +0200 Subject: [PATCH 23/30] Do not fail with --replace-originals These archive should not crash extraction when using --replace-originals Reported-by: Smascer @Smascer Reported-by: Bryan Sutula @sutula Reference: https://github.com/nexB/extractcode/issues/31 Reference: https://github.com/nexB/scancode-toolkit/issues/2723 Signed-off-by: Philippe Ombredanne --- src/extractcode/extract.py | 8 ++++++++ tests/data/cli/replace-originals/issue6550.gz | Bin 0 -> 65536 bytes .../cli/replace-originals/issue6550.gz.ABOUT | 1 + tests/data/cli/replace-originals/rake.1.gz | Bin 0 -> 1370 bytes .../data/cli/replace-originals/rake.1.gz.ABOUT | 1 + tests/test_extract.py | 14 ++++++++------ tests/test_extractcode_cli.py | 16 ++++++++++++++++ 7 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 tests/data/cli/replace-originals/issue6550.gz create mode 100644 tests/data/cli/replace-originals/issue6550.gz.ABOUT create mode 100644 tests/data/cli/replace-originals/rake.1.gz create mode 100644 tests/data/cli/replace-originals/rake.1.gz.ABOUT diff --git a/src/extractcode/extract.py b/src/extractcode/extract.py index 6c220c1..8778951 100644 --- a/src/extractcode/extract.py +++ b/src/extractcode/extract.py @@ -126,9 +126,17 @@ def extract( processed_events_append = processed_events.append for event in extract_events: yield event + if event.warnings or event.errors: + if TRACE: + logger.debug( + f'extract:replace_originals: {event} has errors. ' + 'not replacing originals' + ) + continue if replace_originals: processed_events_append(event) + # move files around when done if replace_originals: for xevent in reversed(processed_events): diff --git a/tests/data/cli/replace-originals/issue6550.gz b/tests/data/cli/replace-originals/issue6550.gz new file mode 100644 index 0000000000000000000000000000000000000000..82b4af102632c3f97bd4957e71ea519751ea0ea8 GIT binary patch literal 65536 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