1# Copyright (C) 2018 The Android Open Source Project
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#      http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""Module to update packages from GitHub archive."""
15
16
17import json
18import re
19import urllib.request
20
21import archive_utils
22import fileutils
23import git_utils
24import metadata_pb2    # pylint: disable=import-error
25import updater_utils
26
27GITHUB_URL_PATTERN = (r'^https:\/\/github.com\/([-\w]+)\/([-\w]+)\/' +
28                      r'(releases\/download\/|archive\/)')
29GITHUB_URL_RE = re.compile(GITHUB_URL_PATTERN)
30
31
32def _edit_distance(str1, str2):
33    prev = list(range(0, len(str2) + 1))
34    for i, chr1 in enumerate(str1):
35        cur = [i + 1]
36        for j, chr2 in enumerate(str2):
37            if chr1 == chr2:
38                cur.append(prev[j])
39            else:
40                cur.append(min(prev[j + 1], prev[j], cur[j]) + 1)
41        prev = cur
42    return prev[len(str2)]
43
44
45def choose_best_url(urls, previous_url):
46    """Returns the best url to download from a list of candidate urls.
47
48    This function calculates similarity between previous url and each of new
49    urls. And returns the one best matches previous url.
50
51    Similarity is measured by editing distance.
52
53    Args:
54        urls: Array of candidate urls.
55        previous_url: String of the url used previously.
56
57    Returns:
58        One url from `urls`.
59    """
60    return min(urls, default=None,
61               key=lambda url: _edit_distance(
62                   url, previous_url))
63
64
65class GithubArchiveUpdater():
66    """Updater for archives from GitHub.
67
68    This updater supports release archives in GitHub. Version is determined by
69    release name in GitHub.
70    """
71
72    VERSION_FIELD = 'tag_name'
73
74    def __init__(self, url, proj_path, metadata):
75        self.proj_path = proj_path
76        self.metadata = metadata
77        self.old_url = url
78        self.owner = None
79        self.repo = None
80        self.new_version = None
81        self.new_url = None
82        self._parse_url(url)
83
84    def _parse_url(self, url):
85        if url.type != metadata_pb2.URL.ARCHIVE:
86            raise ValueError('Only archive url from Github is supported.')
87        match = GITHUB_URL_RE.match(url.value)
88        if match is None:
89            raise ValueError('Url format is not supported.')
90        try:
91            self.owner, self.repo = match.group(1, 2)
92        except IndexError:
93            raise ValueError('Url format is not supported.')
94
95    def _fetch_latest_version(self):
96        """Checks upstream and gets the latest release tag."""
97
98        url = 'https://api.github.com/repos/{}/{}/releases/latest'.format(
99            self.owner, self.repo)
100        with urllib.request.urlopen(url) as request:
101            data = json.loads(request.read().decode())
102        self.new_version = data[self.VERSION_FIELD]
103
104        supported_assets = [
105            a['browser_download_url'] for a in data['assets']
106            if archive_utils.is_supported_archive(a['browser_download_url'])]
107
108        # Adds source code urls.
109        supported_assets.append(
110            'https://github.com/{}/{}/archive/{}.tar.gz'.format(
111                self.owner, self.repo, data.get('tag_name')))
112        supported_assets.append(
113            'https://github.com/{}/{}/archive/{}.zip'.format(
114                self.owner, self.repo, data.get('tag_name')))
115
116        self.new_url = choose_best_url(supported_assets, self.old_url.value)
117
118    def _fetch_latest_commit(self):
119        """Checks upstream and gets the latest commit to master."""
120
121        url = 'https://api.github.com/repos/{}/{}/commits/master'.format(
122            self.owner, self.repo)
123        with urllib.request.urlopen(url) as request:
124            data = json.loads(request.read().decode())
125        self.new_version = data['sha']
126        self.new_url = 'https://github.com/{}/{}/archive/{}.zip'.format(
127            self.owner, self.repo, self.new_version)
128
129    def get_current_version(self):
130        """Returns the latest version name recorded in METADATA."""
131        return self.metadata.third_party.version
132
133    def get_latest_version(self):
134        """Returns the latest version name in upstream."""
135        return self.new_version
136
137    def _write_metadata(self, url, path):
138        updated_metadata = metadata_pb2.MetaData()
139        updated_metadata.CopyFrom(self.metadata)
140        updated_metadata.third_party.version = self.new_version
141        for metadata_url in updated_metadata.third_party.url:
142            if metadata_url == self.old_url:
143                metadata_url.value = url
144        fileutils.write_metadata(path, updated_metadata)
145
146    def check(self):
147        """Checks update for package.
148
149        Returns True if a new version is available.
150        """
151        current = self.get_current_version()
152        if git_utils.is_commit(current):
153            self._fetch_latest_commit()
154        else:
155            self._fetch_latest_version()
156        print('Current version: {}. Latest version: {}'.format(
157            current, self.new_version), end='')
158
159    def update(self):
160        """Updates the package.
161
162        Has to call check() before this function.
163        """
164        temporary_dir = None
165        try:
166            temporary_dir = archive_utils.download_and_extract(self.new_url)
167            package_dir = archive_utils.find_archive_root(temporary_dir)
168            self._write_metadata(self.new_url, package_dir)
169            updater_utils.replace_package(package_dir, self.proj_path)
170        finally:
171            # Don't remove the temporary directory, or it'll be impossible
172            # to debug the failure...
173            # shutil.rmtree(temporary_dir, ignore_errors=True)
174            urllib.request.urlcleanup()
175