albertyw/req-update

View on GitHub
req_update/node.py

Summary

Maintainability
A
0 mins
Test Coverage
A
99%
from __future__ import annotations
import json
import os
import re
import subprocess
from typing import TYPE_CHECKING, cast

from req_update.util import Updater


# Copied and simplified from
# https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
SEMVER = re.compile(r'^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)$')  # NOQA


class Node(Updater):
    def check_applicable(self) -> bool:
        command = ['which', 'npm']
        try:
            self.util.execute_shell(command, True, suppress_output=True)
        except subprocess.CalledProcessError:
            # Cannot find npm
            return False
        files = os.listdir('.')
        if 'package.json' not in files or 'package-lock.json' not in files:
            # Cannot find npm config files
            return False
        return True

    def update_dependencies(self) -> bool:
        """
        Update dependencies
        Return if updates were made
        """
        updated_unpinned = self.update_unpinned_dependencies()
        updated_pinned = self.update_pinned_dependencies()
        updated = updated_unpinned or updated_pinned
        if not updated:
            self.util.warn('No %s updates' % self.language)
        return updated

    def update_unpinned_dependencies(self) -> bool:
        command = ['npm', 'update']
        self.util.log('Updating npm packages')
        self.util.execute_shell(command, False)
        try:
            self.util.check_repository_cleanliness()
            return False  # repository is clean so nothing to commit or push
        except RuntimeError:
            self.util.commit_git('Update npm packages')
            return True

    def update_pinned_dependencies(self) -> bool:
        packages = self.get_outdated()
        if not packages:
            return False
        any_updated = False
        for package_name, package in packages.items():
            updated = self.update_package(package_name, package)
            if updated:
                any_updated = True
        return any_updated

    def get_outdated(self) -> dict[str, dict[str, str]]:
        command = ['npm', 'outdated', '--json']
        result = self.util.execute_shell(command, True, ignore_exit_code=True)
        packages = json.loads(result.stdout)
        if TYPE_CHECKING:
            return cast(dict[str, dict[str, str]], packages)
        return packages

    def update_package(
        self, package_name: str, package: dict[str, str]
    ) -> bool:
        self.util.log('Updating dependency: %s' % package_name)
        with open('package.json', 'r') as handle:
            package_json_string = handle.read()
        package_json = json.loads(package_json_string)
        if package_name in package_json.get('dependencies', {}):
            old_version = package_json['dependencies'][package_name]
            package_json['dependencies'] = self.update_package_dependencies(
                package_json['dependencies'],
                package_name,
                package,
            )
            new_version = package_json['dependencies'][package_name]
        elif package_name in package_json.get('devDependencies', {}):
            old_version = package_json['devDependencies'][package_name]
            package_json['devDependencies'] = self.update_package_dependencies(
                package_json['devDependencies'],
                package_name,
                package,
            )
            new_version = package_json['devDependencies'][package_name]
        else:
            return False
        package_json_string = json.dumps(package_json, indent=2)
        package_json_string += '\n'  # Add the traditional EOF newline
        if not self.util.dry_run:
            with open('package.json', 'w') as handle:
                handle.write(package_json_string)
        success = self.install_dependencies()
        if not success:
            self.util.warn(
                'Dependency conflict; rolling back: %s' % package_name
            )
            self.util.reset_changes()
            return False
        self.util.check_major_version_update(
            package_name, old_version, new_version
        )
        self.util.commit_dependency_update(self.language, package_name, new_version)
        return True

    def update_package_dependencies(
        self,
        dependencies: dict[str, str],
        package_name: str,
        package: dict[str, str],
    ) -> dict[str, str]:
        new_version = package['latest']
        new_version = Node.generate_package_version(new_version)
        dependencies[package_name] = new_version
        return dependencies

    @staticmethod
    def generate_package_version(version: str) -> str:
        """
        Given a version, generate a version specifier that allows updates
        within the most recent non-zero version for semver versions
        """
        match = SEMVER.match(version)
        if not match:
            return version
        versions = match.groupdict()
        if versions['major'] != '0':
            return '^%s.0.0' % versions['major']
        if versions['minor'] != '0':
            return '^0.%s.0' % versions['minor']
        if versions['patch'] != '0':
            return version
        raise ValueError('Cannot compute version')  # pragma: no cover

    def install_dependencies(self) -> bool:
        command = ['npm', 'install']
        try:
            result = self.util.execute_shell(
                command,
                False,
                suppress_output=True,
            )
            if 'Could not resolve dependency' in result.stderr:
                return False
        except subprocess.CalledProcessError as error:
            if 'Could not resolve dependency' in error.stderr:
                return False
            self.util.log(error.stdout)
            self.util.warn(error.stderr)
            raise
        return True