albertyw/req-update

View on GitHub
req_update/gitsubmodule.py

Summary

Maintainability
A
1 hr
Test Coverage
A
100%
from __future__ import annotations

import datetime
from pathlib import Path
import subprocess
from typing import List, NamedTuple, Optional

from req_update.util import Updater


class GitSubmodule(Updater):
    def check_applicable(self) -> bool:
        command = ['git', 'submodule']
        try:
            result = self.util.execute_shell(
                command,
                True,
                suppress_output=True,
            )
        except subprocess.CalledProcessError:
            return False
        return len(result.stdout) > 0

    def update_dependencies(self) -> bool:
        submodules = self.get_submodule_info()
        clean = True
        for submodule in submodules:
            self.util.log('Checking dependency: %s' % submodule.path)
            submodule = self.annotate_submodule(submodule)
            version = self.update_submodule(submodule)
            if version:
                try:
                    # Not easy to tell if a git submodule has changed
                    self.util.check_repository_cleanliness()
                except RuntimeError:
                    self.util.commit_dependency_update(
                        self.language, str(submodule.path), version
                    )
                    clean = False
        return not clean

    # TODO: Make this a method on Submodule
    def get_submodule_info(self) -> List[Submodule]:
        command = ['git', 'submodule']
        result = self.util.execute_shell(command, True)
        submodules: List[Submodule] = []
        for line in result.stdout.split('\n'):
            if not line:
                continue
            location = Path(line.strip().split(' ')[1])
            submodule = Submodule(path=location)
            submodules.append(submodule)
        return submodules

    def annotate_submodule(self, submodule: Submodule) -> Submodule:
        command = ['git', 'fetch', '-tp']
        self.util.execute_shell(command, True, cwd=submodule.path)
        submodule.remote_commit = self.get_remote_commit(submodule)
        submodule.remote_tag = self.get_remote_tag(submodule)
        return submodule

    def get_remote_commit(self, submodule: Submodule) -> VersionInfo:
        command = [
            'git',
            'show',
            'origin',
            '--date=iso-strict',
            '--quiet',
            '--format=%H%n%cd',
        ]
        result = self.util.execute_shell(command, True, cwd=submodule.path)
        return GitSubmodule.get_version_info(result.stdout, '')

    def get_remote_tag(self, submodule: Submodule) -> Optional[VersionInfo]:
        command = ['git', 'tag']
        result = self.util.execute_shell(command, True, cwd=submodule.path)
        if not result.stdout.strip():
            return None
        tags = result.stdout.strip().split('\n')
        try:
            # Attempt to semantically sort tags
            tags = sorted(tags, key=lambda t: int(t.split('.')[2]))
            tags = sorted(tags, key=lambda t: int(t.split('.')[1]))
            tags = sorted(tags, key=lambda t: int(t.strip('v').split('.')[0]))
        except (IndexError, ValueError):
            pass
        tag = tags[-1]
        command = [
            'git',
            'show',
            tag,
            '--date=iso-strict',
            '--quiet',
            '--format=%H%n%cd',
        ]
        result = self.util.execute_shell(command, True, cwd=submodule.path)
        return GitSubmodule.get_version_info(result.stdout, tag)

    @staticmethod
    def get_version_info(commit_data: str, tag_name: str = '') -> VersionInfo:
        lines = commit_data.strip().split('\n')
        if tag_name:
            version_name = tag_name
        else:
            version_name = lines[-2]
        version_date_raw = lines[-1]
        version_date = datetime.datetime.fromisoformat(version_date_raw)
        version_info = VersionInfo(
            version_name=version_name,
            version_date=version_date,
        )
        return version_info

    def update_submodule(self, submodule: Submodule) -> Optional[str]:
        if not submodule.remote_tag and not submodule.remote_commit:
            return None
        version = ''
        if submodule.remote_tag and submodule.remote_commit:
            if (
                submodule.remote_tag.version_date + datetime.timedelta(days=30)
                > submodule.remote_commit.version_date
            ):
                version = submodule.remote_tag.version_name
            else:
                version = submodule.remote_commit.version_name
        elif submodule.remote_commit:
            version = submodule.remote_commit.version_name
        elif submodule.remote_tag:
            version = submodule.remote_tag.version_name
        command = ['git', 'checkout', version]
        self.util.execute_shell(command, False, cwd=submodule.path)
        return version


# TODO: After python 3.7 support is dropped, switch this to a TypedDict
class Submodule:
    def __init__(self, path: Path) -> None:
        self.path: Path = path
        self.remote_tag: Optional[VersionInfo] = None
        self.remote_commit: Optional[VersionInfo] = None


# TODO: After python 3.7 support is dropped, switch this to a TypedDict
class VersionInfo(NamedTuple):
    version_name: str
    version_date: datetime.datetime