azdev/operations/pypi.py (275 lines of code) (raw):

# ----------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. # ----------------------------------------------------------------------------- import os import re import sys from docutils import core, io from knack.log import get_logger from knack.util import CLIError from azdev.utilities import ( display, heading, subheading, cmd, py_cmd, get_path_table, pip_cmd, COMMAND_MODULE_PREFIX, require_azure_cli, find_files) logger = get_logger(__name__) HISTORY_NAME = 'HISTORY.rst' RELEASE_HISTORY_TITLE = 'Release History' SETUP_PY_NAME = 'setup.py' # modules which should not be included in setup.py # because they aren't on PyPI EXCLUDED_MODULES = ['azure-cli-testsdk'] # region verify History Headings def check_history(): # TODO: Does not work with extensions path_table = get_path_table() selected_modules = list(path_table['core'].items()) heading('Verify History') module_names = sorted([name for name, _ in selected_modules]) display('Verifying README and HISTORY files for modules: {}'.format(' '.join(module_names))) failed_mods = [] for name, path in selected_modules: errors = _check_readme_render(path) if errors: failed_mods.append(name) subheading('{} errors'.format(name)) for error in errors: logger.error('%s\n', error) subheading('Results') if failed_mods: display('The following modules have invalid README/HISTORYs:') logger.error('\n'.join(failed_mods)) logger.warning('See above for the full warning/errors') logger.warning('note: Line numbers in the errors map to the long_description of your setup.py.') sys.exit(1) display('OK') def _check_history_headings(mod_path): history_path = os.path.join(mod_path, HISTORY_NAME) source_path = None destination_path = None errors = [] with open(history_path, 'r') as f: input_string = f.read() _, pub = core.publish_programmatically( source_class=io.StringInput, source=input_string, source_path=source_path, destination_class=io.NullOutput, destination=None, destination_path=destination_path, reader=None, reader_name='standalone', parser=None, parser_name='restructuredtext', writer=None, writer_name='null', settings=None, settings_spec=None, settings_overrides={}, config_section=None, enable_exit_status=None) # Check first heading is Release History if pub.writer.document.children[0].rawsource != RELEASE_HISTORY_TITLE: errors.append("Expected '{}' as first heading in HISTORY.rst".format(RELEASE_HISTORY_TITLE)) all_versions = [t['names'][0] for t in pub.writer.document.children if t['names']] # Check that no headings contain 'unreleased'. We don't require it any more if any('unreleased' in v.lower() for v in all_versions): errors.append("We no longer require 'unreleased' in headings. Use the appropriate version number instead.") # Check that the current package version has a history entry if not all_versions: errors.append("Unable to get versions from {}. Check formatting. e.g. there should be a new " "line after the 'Release History' heading.".format(history_path)) first_version_history = all_versions[0] actual_version = cmd(sys.executable + ' setup.py --version', cwd=mod_path) # command can output warnings as well, so we just want the last line, which should have the version actual_version = actual_version.result.splitlines()[-1].strip() if first_version_history != actual_version: errors.append("The topmost version in {} does not match version {} defined in setup.py.".format( history_path, actual_version)) return errors def _check_readme_render(mod_path): errors = [] result = cmd(sys.executable + ' setup.py check -r -s', cwd=mod_path) if result.exit_code: # this outputs some warnings we don't care about error_lines = [] target_line = 'The following syntax errors were detected' suppress = True logger.debug(result.error.output) # TODO: Checks for syntax errors but potentially not other things for line in result.error.output.splitlines(): line = str(line).strip() if not suppress and line: error_lines.append(line) if target_line in line: suppress = False errors.append(os.linesep.join(error_lines)) errors += _check_history_headings(mod_path) return errors # endregion # region verify PyPI versions def verify_versions(): import tempfile import shutil require_azure_cli() heading('Verify CLI Versions') path_table = get_path_table() modules = list(path_table['core'].items()) modules = [x for x in modules if x[0] not in EXCLUDED_MODULES] if not modules: raise CLIError('No modules selected to test.') display('MODULES: {}'.format(', '.join([x[0] for x in modules]))) results = {} original_cwd = os.getcwd() temp_dir = tempfile.mkdtemp() for mod, mod_path in modules: if not mod.startswith(COMMAND_MODULE_PREFIX) and mod != 'azure-cli': mod = '{}{}'.format(COMMAND_MODULE_PREFIX, mod) results[mod] = {} results.update(_compare_module_against_pypi(results, temp_dir, mod, mod_path)) shutil.rmtree(temp_dir) os.chdir(original_cwd) logger.info('Module'.ljust(40) + 'Local Version'.rjust(20) + 'Public Version'.rjust(20)) # pylint: disable=logging-not-lazy for mod, data in results.items(): logger.info(mod.ljust(40) + data['local_version'].rjust(20) + data['public_version'].rjust(20)) bump_mods = {k: v for k, v in results.items() if v['status'] == 'BUMP'} subheading('RESULTS') if bump_mods: logger.error('The following modules need their versions bumped. ' 'Scroll up for details: %s', ', '.join(bump_mods.keys())) logger.warning('\nNote that before changing versions, you should consider ' 'running `git clean` to remove untracked files from your repo. ' 'Files that were once tracked but removed from the source may ' 'still be on your machine, resuling in false positives.') sys.exit(1) else: display('OK!') def _get_module_versions(results, modules): version_pattern = re.compile(r'.*(?P<ver>\d+.\d+.\d+).*') for mod, mod_path in modules: if not mod.startswith(COMMAND_MODULE_PREFIX) and mod != 'azure-cli': mod = '{}{}'.format(COMMAND_MODULE_PREFIX, mod) setup_path = find_files(mod_path, 'setup.py') with open(setup_path[0], 'r') as f: local_version = 'Unknown' for line in f.readlines(): if line.strip().startswith('VERSION'): local_version = version_pattern.match(line).group('ver') break results[mod]['local_version'] = local_version return results # pylint: disable=too-many-statements def _compare_module_against_pypi(results, root_dir, mod, mod_path): import zipfile version_pattern = re.compile(r'.*azure_cli[^-]*-(\d*.\d*.\d*).*') downloaded_path = None downloaded_version = None build_path = None build_version = None build_dir = os.path.join(root_dir, mod, 'local') pypi_dir = os.path.join(root_dir, mod, 'public') # download the public PyPI package and extract the version logger.info('Checking %s...', mod) result = pip_cmd('download {} --no-deps -d {}'.format(mod, root_dir)).result try: result = result.decode('utf-8') except AttributeError: pass for line in result.splitlines(): line = line.strip() if line.endswith('.whl') and line.startswith('Saved'): downloaded_path = line.replace('Saved ', '').strip() downloaded_version = version_pattern.match(downloaded_path).group(1) break if 'No matching distribution found' in line: downloaded_path = None downloaded_version = 'Unavailable' break if not downloaded_version: raise CLIError('Unexpected error trying to acquire {}: {}'.format(mod, result)) # build from source and extract the version setup_path = os.path.normpath(mod_path.strip()) os.chdir(setup_path) py_cmd('setup.py bdist_wheel -d {}'.format(build_dir)) if len(os.listdir(build_dir)) != 1: raise CLIError('Unexpectedly found multiple build files found in {}.'.format(build_dir)) build_path = os.path.join(build_dir, os.listdir(build_dir)[0]) build_version = version_pattern.match(build_path).group(1) results[mod].update({ 'local_version': build_version, 'public_version': downloaded_version }) # OK if package is new if downloaded_version == 'Unavailable': results[mod]['status'] = 'OK' return results # OK if local version is higher than what's on PyPI from distutils.version import LooseVersion # pylint:disable=import-error,no-name-in-module,deprecated-module if LooseVersion(build_version) > LooseVersion(downloaded_version): results[mod]['status'] = 'OK' return results # slight difference in dist-info dirs, so we must extract the azure folders and compare them with zipfile.ZipFile(str(downloaded_path), 'r') as z: z.extractall(pypi_dir) with zipfile.ZipFile(str(build_path), 'r') as z: z.extractall(build_dir) errors = _compare_folders(os.path.join(pypi_dir), os.path.join(build_dir)) # clean up empty strings errors = [e for e in errors if e] if errors: subheading('Differences found in {}'.format(mod)) for error in errors: logger.warning(error) results[mod]['status'] = 'OK' if not errors else 'BUMP' # special case: to make a release, these MUST be bumped, even if it wouldn't otherwise be necessary if mod in ['azure-cli', 'azure-cli-core']: if results[mod]['status'] == 'OK': logger.warning('%s version must be bumped to support release!', mod) results[mod]['status'] = 'BUMP' return results def _diff_files(filename, dir1, dir2): import difflib file1 = os.path.join(dir1, filename) file2 = os.path.join(dir2, filename) errors = [] with open(file1, 'r') as f1, open(file2, 'r') as f2: errors.append(os.linesep.join(diff for diff in difflib.context_diff(f1.readlines(), f2.readlines()))) return errors def _compare_common_files(common_files, dir1, dir2): errors = [] for filename in common_files: errors = errors + _diff_files(filename, dir1, dir2) return errors def _compare_folders(dir1, dir2): import filecmp dirs_cmp = filecmp.dircmp(dir1, dir2) errors = [] if dirs_cmp.left_only or dirs_cmp.right_only or dirs_cmp.funny_files: # allow some special cases if len(dirs_cmp.left_only) == 1 and '__init__.py' in dirs_cmp.left_only: pass elif len(dirs_cmp.right_only) == 1 and dirs_cmp.right_only[0].endswith('.whl'): pass else: if dirs_cmp.left_only: logger.debug('LO: %s', dirs_cmp.left_only) if dirs_cmp.right_only: logger.debug('RO: %s', dirs_cmp.right_only) if dirs_cmp.funny_files: logger.debug('FF: %s', dirs_cmp.funny_files) errors.append('Different files in directory structure.') errors = errors + _compare_common_files(dirs_cmp.common_files, dir1, dir2) for common_dir in dirs_cmp.common_dirs: new_dir1 = os.path.join(dir1, common_dir) new_dir2 = os.path.join(dir2, common_dir) if common_dir.endswith('.dist-info'): # special case to check for dependency-only changes errors = errors + _compare_dependencies(new_dir1, new_dir2) else: errors = errors + _compare_folders(new_dir1, new_dir2) return errors def _extract_dependencies(path): dependencies = {} with open(path, 'r') as f: for line in f.readlines(): if line.startswith('Requires-Dist:'): line = line.replace(' ;', '').replace(';', '') comps = line.split(' ', 2) if len(comps) == 2: dependencies[comps[1]] = '_ANY_' elif len(comps) > 2: dependencies[comps[1]] = comps[2] else: raise CLIError('Unrecognized format in METADATA: {}'.format(line)) return dependencies def _compare_dependencies(dir1, dir2): deps1 = _extract_dependencies(os.path.join(dir1, 'METADATA')) deps2 = _extract_dependencies(os.path.join(dir2, 'METADATA')) errors = [] mismatch = {} matched = [] for key, val in deps1.items(): if key in deps2: if deps2[key] != val: mismatch[key] = '{} != {}'.format(val, deps2[key]) deps2.pop(key) matched.append(key) for key in matched: deps1.pop(key) for key, val in deps2.items(): if key in deps1: if deps1[key] != val: mismatch[key] = '{} != {}'.format(val, deps1[key]) deps1.pop(key) if deps1: errors.append('New dependencies: {}'.format(deps1)) if deps2: errors.append('Removed dependencies: {}'.format(deps2)) if mismatch: errors.append('Changed dependencies: {}'.format(mismatch)) return errors # endregion