xref: /aosp_15_r20/tools/external_updater/crates_updater.py (revision 3c875a214f382db1236d28570d1304ce57138f32)
1*3c875a21SAndroid Build Coastguard Worker# Copyright (C) 2020 The Android Open Source Project
2*3c875a21SAndroid Build Coastguard Worker#
3*3c875a21SAndroid Build Coastguard Worker# Licensed under the Apache License, Version 2.0 (the "License");
4*3c875a21SAndroid Build Coastguard Worker# you may not use this file except in compliance with the License.
5*3c875a21SAndroid Build Coastguard Worker# You may obtain a copy of the License at
6*3c875a21SAndroid Build Coastguard Worker#
7*3c875a21SAndroid Build Coastguard Worker#      http://www.apache.org/licenses/LICENSE-2.0
8*3c875a21SAndroid Build Coastguard Worker#
9*3c875a21SAndroid Build Coastguard Worker# Unless required by applicable law or agreed to in writing, software
10*3c875a21SAndroid Build Coastguard Worker# distributed under the License is distributed on an "AS IS" BASIS,
11*3c875a21SAndroid Build Coastguard Worker# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12*3c875a21SAndroid Build Coastguard Worker# See the License for the specific language governing permissions and
13*3c875a21SAndroid Build Coastguard Worker# limitations under the License.
14*3c875a21SAndroid Build Coastguard Worker"""Module to check updates from crates.io."""
15*3c875a21SAndroid Build Coastguard Worker
16*3c875a21SAndroid Build Coastguard Workerimport json
17*3c875a21SAndroid Build Coastguard Workerimport os
18*3c875a21SAndroid Build Coastguard Workerfrom pathlib import Path
19*3c875a21SAndroid Build Coastguard Workerimport re
20*3c875a21SAndroid Build Coastguard Workerimport shutil
21*3c875a21SAndroid Build Coastguard Workerimport tempfile
22*3c875a21SAndroid Build Coastguard Workerimport urllib.request
23*3c875a21SAndroid Build Coastguard Workerfrom typing import IO
24*3c875a21SAndroid Build Coastguard Worker
25*3c875a21SAndroid Build Coastguard Workerimport archive_utils
26*3c875a21SAndroid Build Coastguard Workerfrom base_updater import Updater
27*3c875a21SAndroid Build Coastguard Workerimport git_utils
28*3c875a21SAndroid Build Coastguard Worker# pylint: disable=import-error
29*3c875a21SAndroid Build Coastguard Workerimport metadata_pb2  # type: ignore
30*3c875a21SAndroid Build Coastguard Workerimport updater_utils
31*3c875a21SAndroid Build Coastguard Worker
32*3c875a21SAndroid Build Coastguard WorkerLIBRARY_NAME_PATTERN: str = r"([-\w]+)"
33*3c875a21SAndroid Build Coastguard Worker
34*3c875a21SAndroid Build Coastguard WorkerALPHA_BETA_PATTERN: str = r"^.*[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta).*"
35*3c875a21SAndroid Build Coastguard Worker
36*3c875a21SAndroid Build Coastguard WorkerALPHA_BETA_RE: re.Pattern = re.compile(ALPHA_BETA_PATTERN)
37*3c875a21SAndroid Build Coastguard Worker
38*3c875a21SAndroid Build Coastguard Worker"""Match both x.y.z and x.y.z+a.b.c which is used by some Vulkan binding libraries"""
39*3c875a21SAndroid Build Coastguard WorkerVERSION_PATTERN: str = r"([0-9]+)\.([0-9]+)\.([0-9]+)(\+([0-9]+)\.([0-9]+)\.([0-9]+))?"
40*3c875a21SAndroid Build Coastguard Worker
41*3c875a21SAndroid Build Coastguard WorkerVERSION_RE: re.Pattern = re.compile(VERSION_PATTERN)
42*3c875a21SAndroid Build Coastguard Worker
43*3c875a21SAndroid Build Coastguard WorkerCRATES_IO_ARCHIVE_URL_PATTERN: str = (r"^https:\/\/static.crates.io\/crates\/" +
44*3c875a21SAndroid Build Coastguard Worker                                      LIBRARY_NAME_PATTERN + "/" +
45*3c875a21SAndroid Build Coastguard Worker                                      LIBRARY_NAME_PATTERN + "-" +
46*3c875a21SAndroid Build Coastguard Worker                                      "(.*?)" + ".crate")
47*3c875a21SAndroid Build Coastguard Worker
48*3c875a21SAndroid Build Coastguard WorkerCRATES_IO_ARCHIVE_URL_RE: re.Pattern = re.compile(CRATES_IO_ARCHIVE_URL_PATTERN)
49*3c875a21SAndroid Build Coastguard Worker
50*3c875a21SAndroid Build Coastguard WorkerDESCRIPTION_PATTERN: str = r"^description *= *(\".+\")"
51*3c875a21SAndroid Build Coastguard Worker
52*3c875a21SAndroid Build Coastguard WorkerDESCRIPTION_RE: re.Pattern = re.compile(DESCRIPTION_PATTERN)
53*3c875a21SAndroid Build Coastguard Worker
54*3c875a21SAndroid Build Coastguard Worker
55*3c875a21SAndroid Build Coastguard Workerclass CratesUpdater(Updater):
56*3c875a21SAndroid Build Coastguard Worker    """Updater for crates.io packages."""
57*3c875a21SAndroid Build Coastguard Worker
58*3c875a21SAndroid Build Coastguard Worker    UPSTREAM_REMOTE_NAME: str = "update_origin"
59*3c875a21SAndroid Build Coastguard Worker    download_url: str
60*3c875a21SAndroid Build Coastguard Worker    package: str
61*3c875a21SAndroid Build Coastguard Worker    package_dir: str
62*3c875a21SAndroid Build Coastguard Worker    temp_file: IO
63*3c875a21SAndroid Build Coastguard Worker
64*3c875a21SAndroid Build Coastguard Worker    def is_supported_url(self) -> bool:
65*3c875a21SAndroid Build Coastguard Worker        match = CRATES_IO_ARCHIVE_URL_RE.match(self._old_identifier.value)
66*3c875a21SAndroid Build Coastguard Worker        if match is None:
67*3c875a21SAndroid Build Coastguard Worker            return False
68*3c875a21SAndroid Build Coastguard Worker        self.package = match.group(1)
69*3c875a21SAndroid Build Coastguard Worker        return True
70*3c875a21SAndroid Build Coastguard Worker
71*3c875a21SAndroid Build Coastguard Worker    def setup_remote(self) -> None:
72*3c875a21SAndroid Build Coastguard Worker        url = "https://crates.io/api/v1/crates/" + self.package
73*3c875a21SAndroid Build Coastguard Worker        with urllib.request.urlopen(url) as request:
74*3c875a21SAndroid Build Coastguard Worker            data = json.loads(request.read().decode())
75*3c875a21SAndroid Build Coastguard Worker        homepage = data["crate"]["repository"]
76*3c875a21SAndroid Build Coastguard Worker        remotes = git_utils.list_remotes(self._proj_path)
77*3c875a21SAndroid Build Coastguard Worker        current_remote_url = None
78*3c875a21SAndroid Build Coastguard Worker        for name, url in remotes.items():
79*3c875a21SAndroid Build Coastguard Worker            if name == self.UPSTREAM_REMOTE_NAME:
80*3c875a21SAndroid Build Coastguard Worker                current_remote_url = url
81*3c875a21SAndroid Build Coastguard Worker
82*3c875a21SAndroid Build Coastguard Worker        if current_remote_url is not None and current_remote_url != homepage:
83*3c875a21SAndroid Build Coastguard Worker            git_utils.remove_remote(self._proj_path, self.UPSTREAM_REMOTE_NAME)
84*3c875a21SAndroid Build Coastguard Worker            current_remote_url = None
85*3c875a21SAndroid Build Coastguard Worker
86*3c875a21SAndroid Build Coastguard Worker        if current_remote_url is None:
87*3c875a21SAndroid Build Coastguard Worker            git_utils.add_remote(self._proj_path, self.UPSTREAM_REMOTE_NAME, homepage)
88*3c875a21SAndroid Build Coastguard Worker
89*3c875a21SAndroid Build Coastguard Worker        branch = git_utils.detect_default_branch(self._proj_path,
90*3c875a21SAndroid Build Coastguard Worker                                                 self.UPSTREAM_REMOTE_NAME)
91*3c875a21SAndroid Build Coastguard Worker        git_utils.fetch(self._proj_path, self.UPSTREAM_REMOTE_NAME, branch)
92*3c875a21SAndroid Build Coastguard Worker
93*3c875a21SAndroid Build Coastguard Worker    def _get_version_numbers(self, version: str) -> tuple[int, int, int]:
94*3c875a21SAndroid Build Coastguard Worker        match = VERSION_RE.match(version)
95*3c875a21SAndroid Build Coastguard Worker        if match is not None:
96*3c875a21SAndroid Build Coastguard Worker            return (
97*3c875a21SAndroid Build Coastguard Worker                int(match.group(1)),
98*3c875a21SAndroid Build Coastguard Worker                int(match.group(2)),
99*3c875a21SAndroid Build Coastguard Worker                int(match.group(3)),
100*3c875a21SAndroid Build Coastguard Worker            )
101*3c875a21SAndroid Build Coastguard Worker        return (0, 0, 0)
102*3c875a21SAndroid Build Coastguard Worker
103*3c875a21SAndroid Build Coastguard Worker    def _is_newer_version(self, prev_version: str, prev_id: int,
104*3c875a21SAndroid Build Coastguard Worker                          check_version: str, check_id: int):
105*3c875a21SAndroid Build Coastguard Worker        """Return true if check_version+id is newer than prev_version+id."""
106*3c875a21SAndroid Build Coastguard Worker        return ((self._get_version_numbers(check_version), check_id) >
107*3c875a21SAndroid Build Coastguard Worker                (self._get_version_numbers(prev_version), prev_id))
108*3c875a21SAndroid Build Coastguard Worker
109*3c875a21SAndroid Build Coastguard Worker    def _find_latest_non_test_version(self) -> None:
110*3c875a21SAndroid Build Coastguard Worker        url = f"https://crates.io/api/v1/crates/{self.package}/versions"
111*3c875a21SAndroid Build Coastguard Worker        with urllib.request.urlopen(url) as request:
112*3c875a21SAndroid Build Coastguard Worker            data = json.loads(request.read().decode())
113*3c875a21SAndroid Build Coastguard Worker        last_id = 0
114*3c875a21SAndroid Build Coastguard Worker        self._new_identifier.version = ""
115*3c875a21SAndroid Build Coastguard Worker        for v in data["versions"]:
116*3c875a21SAndroid Build Coastguard Worker            version = v["num"]
117*3c875a21SAndroid Build Coastguard Worker            if (not v["yanked"] and not ALPHA_BETA_RE.match(version) and
118*3c875a21SAndroid Build Coastguard Worker                self._is_newer_version(
119*3c875a21SAndroid Build Coastguard Worker                    self._new_identifier.version, last_id, version, int(v["id"]))):
120*3c875a21SAndroid Build Coastguard Worker                last_id = int(v["id"])
121*3c875a21SAndroid Build Coastguard Worker                self._new_identifier.version = version
122*3c875a21SAndroid Build Coastguard Worker                self.download_url = "https://crates.io" + v["dl_path"]
123*3c875a21SAndroid Build Coastguard Worker
124*3c875a21SAndroid Build Coastguard Worker    def check(self) -> None:
125*3c875a21SAndroid Build Coastguard Worker        """Checks crates.io and returns whether a new version is available."""
126*3c875a21SAndroid Build Coastguard Worker        url = "https://crates.io/api/v1/crates/" + self.package
127*3c875a21SAndroid Build Coastguard Worker        with urllib.request.urlopen(url) as request:
128*3c875a21SAndroid Build Coastguard Worker            data = json.loads(request.read().decode())
129*3c875a21SAndroid Build Coastguard Worker            self._new_identifier.version = data["crate"]["max_version"]
130*3c875a21SAndroid Build Coastguard Worker        # Skip d.d.d-{alpha,beta}* versions
131*3c875a21SAndroid Build Coastguard Worker        if ALPHA_BETA_RE.match(self._new_identifier.version):
132*3c875a21SAndroid Build Coastguard Worker            print(f"Ignore alpha or beta release:{self.package}-{self._new_identifier.version}.")
133*3c875a21SAndroid Build Coastguard Worker            self._find_latest_non_test_version()
134*3c875a21SAndroid Build Coastguard Worker        else:
135*3c875a21SAndroid Build Coastguard Worker            url = url + "/" + self._new_identifier.version
136*3c875a21SAndroid Build Coastguard Worker            with urllib.request.urlopen(url) as request:
137*3c875a21SAndroid Build Coastguard Worker                data = json.loads(request.read().decode())
138*3c875a21SAndroid Build Coastguard Worker                self.download_url = "https://crates.io" + data["version"]["dl_path"]
139*3c875a21SAndroid Build Coastguard Worker
140*3c875a21SAndroid Build Coastguard Worker    def set_new_version_to_old(self):
141*3c875a21SAndroid Build Coastguard Worker        super().refresh_without_upgrading()
142*3c875a21SAndroid Build Coastguard Worker        # A shortcut to use the static download path.
143*3c875a21SAndroid Build Coastguard Worker        self.download_url = f"https://static.crates.io/crates/{self.package}/" \
144*3c875a21SAndroid Build Coastguard Worker                            f"{self.package}-{self._new_identifier.version}.crate"
145*3c875a21SAndroid Build Coastguard Worker
146*3c875a21SAndroid Build Coastguard Worker    def update(self) -> None:
147*3c875a21SAndroid Build Coastguard Worker        """Updates the package.
148*3c875a21SAndroid Build Coastguard Worker
149*3c875a21SAndroid Build Coastguard Worker        Has to call check() before this function.
150*3c875a21SAndroid Build Coastguard Worker        """
151*3c875a21SAndroid Build Coastguard Worker        try:
152*3c875a21SAndroid Build Coastguard Worker            temporary_dir = archive_utils.download_and_extract(self.download_url)
153*3c875a21SAndroid Build Coastguard Worker            self.package_dir = archive_utils.find_archive_root(temporary_dir)
154*3c875a21SAndroid Build Coastguard Worker            self.temp_file = tempfile.NamedTemporaryFile()
155*3c875a21SAndroid Build Coastguard Worker            updater_utils.replace_package(self.package_dir, self._proj_path,
156*3c875a21SAndroid Build Coastguard Worker                                          self.temp_file.name)
157*3c875a21SAndroid Build Coastguard Worker            self.check_for_errors()
158*3c875a21SAndroid Build Coastguard Worker        finally:
159*3c875a21SAndroid Build Coastguard Worker            urllib.request.urlcleanup()
160*3c875a21SAndroid Build Coastguard Worker
161*3c875a21SAndroid Build Coastguard Worker    def rollback(self) -> bool:
162*3c875a21SAndroid Build Coastguard Worker        # Only rollback if we have already swapped,
163*3c875a21SAndroid Build Coastguard Worker        # which we denote by writing to this file.
164*3c875a21SAndroid Build Coastguard Worker        if os.fstat(self.temp_file.fileno()).st_size > 0:
165*3c875a21SAndroid Build Coastguard Worker            tmp_dir = tempfile.TemporaryDirectory()
166*3c875a21SAndroid Build Coastguard Worker            shutil.move(self._proj_path, tmp_dir.name)
167*3c875a21SAndroid Build Coastguard Worker            shutil.move(self.package_dir, self._proj_path)
168*3c875a21SAndroid Build Coastguard Worker            shutil.move(Path(tmp_dir.name) / self.package, self.package_dir)
169*3c875a21SAndroid Build Coastguard Worker            return True
170*3c875a21SAndroid Build Coastguard Worker        return False
171*3c875a21SAndroid Build Coastguard Worker
172*3c875a21SAndroid Build Coastguard Worker    def update_metadata(self, metadata: metadata_pb2.MetaData) -> metadata_pb2:
173*3c875a21SAndroid Build Coastguard Worker        """Updates METADATA content."""
174*3c875a21SAndroid Build Coastguard Worker        # copy only HOMEPAGE url, and then add new ARCHIVE url.
175*3c875a21SAndroid Build Coastguard Worker        updated_metadata = super().update_metadata(metadata)
176*3c875a21SAndroid Build Coastguard Worker        for identifier in updated_metadata.third_party.identifier:
177*3c875a21SAndroid Build Coastguard Worker            if identifier.version:
178*3c875a21SAndroid Build Coastguard Worker                identifier.value = f"https://static.crates.io/crates/" \
179*3c875a21SAndroid Build Coastguard Worker                                   f"{updated_metadata.name}/"\
180*3c875a21SAndroid Build Coastguard Worker                                   f"{updated_metadata.name}" \
181*3c875a21SAndroid Build Coastguard Worker                                   f"-{self.latest_identifier.version}.crate"
182*3c875a21SAndroid Build Coastguard Worker                break
183*3c875a21SAndroid Build Coastguard Worker        # copy description from Cargo.toml to METADATA
184*3c875a21SAndroid Build Coastguard Worker        cargo_toml = os.path.join(self.project_path, "Cargo.toml")
185*3c875a21SAndroid Build Coastguard Worker        description = self._get_cargo_description(cargo_toml)
186*3c875a21SAndroid Build Coastguard Worker        if description and description != updated_metadata.description:
187*3c875a21SAndroid Build Coastguard Worker            print("New METADATA description:", description)
188*3c875a21SAndroid Build Coastguard Worker            updated_metadata.description = description
189*3c875a21SAndroid Build Coastguard Worker        return updated_metadata
190*3c875a21SAndroid Build Coastguard Worker
191*3c875a21SAndroid Build Coastguard Worker    def check_for_errors(self) -> None:
192*3c875a21SAndroid Build Coastguard Worker        # Check for .rej patches from failing to apply patches.
193*3c875a21SAndroid Build Coastguard Worker        # If this has too many false positives, we could either
194*3c875a21SAndroid Build Coastguard Worker        # check if the files are modified by patches or somehow
195*3c875a21SAndroid Build Coastguard Worker        # track which files existed before the patching.
196*3c875a21SAndroid Build Coastguard Worker        rejects = list(self._proj_path.glob('**/*.rej'))
197*3c875a21SAndroid Build Coastguard Worker        if len(rejects) > 0:
198*3c875a21SAndroid Build Coastguard Worker            print(f"Error: Found patch reject files: {str(rejects)}")
199*3c875a21SAndroid Build Coastguard Worker            self._has_errors = True
200*3c875a21SAndroid Build Coastguard Worker
201*3c875a21SAndroid Build Coastguard Worker    def _toml2str(self, line: str) -> str:
202*3c875a21SAndroid Build Coastguard Worker        """Convert a quoted toml string to a Python str without quotes."""
203*3c875a21SAndroid Build Coastguard Worker        if line.startswith("\"\"\""):
204*3c875a21SAndroid Build Coastguard Worker            return ""  # cannot handle broken multi-line description
205*3c875a21SAndroid Build Coastguard Worker        # TOML string escapes: \b \t \n \f \r \" \\ (no unicode escape)
206*3c875a21SAndroid Build Coastguard Worker        line = line[1:-1].replace("\\\\", "\n").replace("\\b", "")
207*3c875a21SAndroid Build Coastguard Worker        line = line.replace("\\t", " ").replace("\\n", " ").replace("\\f", " ")
208*3c875a21SAndroid Build Coastguard Worker        line = line.replace("\\r", "").replace("\\\"", "\"").replace("\n", "\\")
209*3c875a21SAndroid Build Coastguard Worker        # replace a unicode quotation mark, used in the libloading crate
210*3c875a21SAndroid Build Coastguard Worker        return line.replace("’", "'").strip()
211*3c875a21SAndroid Build Coastguard Worker
212*3c875a21SAndroid Build Coastguard Worker    def _get_cargo_description(self, cargo_toml: str) -> str:
213*3c875a21SAndroid Build Coastguard Worker        """Return the description in Cargo.toml or empty string."""
214*3c875a21SAndroid Build Coastguard Worker        if os.path.isfile(cargo_toml) and os.access(cargo_toml, os.R_OK):
215*3c875a21SAndroid Build Coastguard Worker            with open(cargo_toml, "r", encoding="utf-8") as toml_file:
216*3c875a21SAndroid Build Coastguard Worker                for line in toml_file:
217*3c875a21SAndroid Build Coastguard Worker                    match = DESCRIPTION_RE.match(line)
218*3c875a21SAndroid Build Coastguard Worker                    if match:
219*3c875a21SAndroid Build Coastguard Worker                        return self._toml2str(match.group(1))
220*3c875a21SAndroid Build Coastguard Worker        return ""
221