Chih-Hung Hsieh | 3d24aed | 2020-10-05 15:29:11 -0700 | [diff] [blame^] | 1 | #!/usr/bin/env python3 |
| 2 | # |
| 3 | # Copyright (C) 2020 The Android Open Source Project |
| 4 | # |
| 5 | # Licensed under the Apache License, Version 2.0 (the "License"); |
| 6 | # you may not use this file except in compliance with the License. |
| 7 | # You may obtain a copy of the License at |
| 8 | # |
| 9 | # http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | # |
| 11 | # Unless required by applicable law or agreed to in writing, software |
| 12 | # distributed under the License is distributed on an "AS IS" BASIS, |
| 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 14 | # See the License for the specific language governing permissions and |
| 15 | # limitations under the License. |
| 16 | """Add files to a Rust package for third party review.""" |
| 17 | |
| 18 | import datetime |
| 19 | import json |
| 20 | import os |
| 21 | import pathlib |
| 22 | import re |
| 23 | |
| 24 | # patterns to match keys in Cargo.toml |
| 25 | NAME_PATTERN = r"^name *= *\"(.+)\"" |
| 26 | NAME_MATCHER = re.compile(NAME_PATTERN) |
| 27 | VERSION_PATTERN = r"^version *= *\"(.+)\"" |
| 28 | VERSION_MATCHER = re.compile(VERSION_PATTERN) |
| 29 | DESCRIPTION_PATTERN = r"^description *= *(\".+\")" |
| 30 | DESCRIPTION_MATCHER = re.compile(DESCRIPTION_PATTERN) |
| 31 | # NOTE: This description one-liner pattern fails to match |
| 32 | # multi-line descriptions in some Rust crates, e.g. shlex. |
| 33 | |
| 34 | # patterns to match year/month/day in METADATA |
| 35 | YMD_PATTERN = r"^ +(year|month|day): (.+)$" |
| 36 | YMD_MATCHER = re.compile(YMD_PATTERN) |
| 37 | YMD_LINE_PATTERN = r"^.* year: *([^ ]+) +month: *([^ ]+) +day: *([^ ]+).*$" |
| 38 | YMD_LINE_MATCHER = re.compile(YMD_LINE_PATTERN) |
| 39 | |
| 40 | # patterns to match Apache/MIT licence in LICENSE* |
| 41 | APACHE_PATTERN = r"^.*Apache License.*$" |
| 42 | APACHE_MATCHER = re.compile(APACHE_PATTERN) |
| 43 | MIT_PATTERN = r"^.*MIT License.*$" |
| 44 | MIT_MATCHER = re.compile(MIT_PATTERN) |
| 45 | BSD_PATTERN = r"^.*BSD .*License.*$" |
| 46 | BSD_MATCHER = re.compile(BSD_PATTERN) |
| 47 | |
| 48 | # default owners added to OWNERS |
| 49 | DEFAULT_OWNERS = "include platform/prebuilts/rust:/OWNERS\n" |
| 50 | |
| 51 | # See b/159487435 Official policy for rust imports METADATA URLs. |
| 52 | # "license_type: NOTICE" might be optional, |
| 53 | # but it is already used in most rust crate METADATA. |
| 54 | # This line format should match the output of external_updater. |
| 55 | METADATA_CONTENT = """name: "{}" |
| 56 | description: {} |
| 57 | third_party {{ |
| 58 | url {{ |
| 59 | type: HOMEPAGE |
| 60 | value: "https://crates.io/crates/{}" |
| 61 | }} |
| 62 | url {{ |
| 63 | type: ARCHIVE |
| 64 | value: "https://static.crates.io/crates/{}/{}-{}.crate" |
| 65 | }} |
| 66 | version: "{}" |
| 67 | license_type: NOTICE |
| 68 | last_upgrade_date {{ |
| 69 | year: {} |
| 70 | month: {} |
| 71 | day: {} |
| 72 | }} |
| 73 | }} |
| 74 | """ |
| 75 | |
| 76 | |
| 77 | def get_metadata_date(): |
| 78 | """Return last_upgrade_date in METADATA or today.""" |
| 79 | # When applied to existing directories to normalize METADATA, |
| 80 | # we don't want to change the last_upgrade_date. |
| 81 | year, month, day = "", "", "" |
| 82 | if os.path.exists("METADATA"): |
| 83 | with open("METADATA", "r") as inf: |
| 84 | for line in inf: |
| 85 | match = YMD_MATCHER.match(line) |
| 86 | if match: |
| 87 | if match.group(1) == "year": |
| 88 | year = match.group(2) |
| 89 | elif match.group(1) == "month": |
| 90 | month = match.group(2) |
| 91 | elif match.group(1) == "day": |
| 92 | day = match.group(2) |
| 93 | else: |
| 94 | match = YMD_LINE_MATCHER.match(line) |
| 95 | if match: |
| 96 | year, month, day = match.group(1), match.group(2), match.group(3) |
| 97 | if year and month and day: |
| 98 | print("### Reuse date in METADATA:", year, month, day) |
| 99 | return int(year), int(month), int(day) |
| 100 | today = datetime.date.today() |
| 101 | return today.year, today.month, today.day |
| 102 | |
| 103 | |
| 104 | def add_metadata(name, version, description): |
| 105 | """Update or add METADATA file.""" |
| 106 | if os.path.exists("METADATA"): |
| 107 | print("### Updating METADATA") |
| 108 | else: |
| 109 | print("### Adding METADATA") |
| 110 | year, month, day = get_metadata_date() |
| 111 | with open("METADATA", "w") as outf: |
| 112 | outf.write(METADATA_CONTENT.format( |
| 113 | name, description, name, name, name, |
| 114 | version, version, year, month, day)) |
| 115 | |
| 116 | |
| 117 | def grep_license_keyword(license_file): |
| 118 | """Find familiar patterns in a file and return the type.""" |
| 119 | with open(license_file, "r") as input_file: |
| 120 | for line in input_file: |
| 121 | if APACHE_MATCHER.match(line): |
| 122 | return "APACHE2" |
| 123 | if MIT_MATCHER.match(line): |
| 124 | return "MIT" |
| 125 | if BSD_MATCHER.match(line): |
| 126 | return "BSD_LIKE" |
| 127 | print("ERROR: cannot decide license type in", license_file, |
| 128 | " assume BSD_LIKE") |
| 129 | return "BSD_LIKE" |
| 130 | |
| 131 | |
| 132 | def decide_license_type(): |
| 133 | """Check LICENSE* files to determine the license type.""" |
| 134 | # Most crates.io packages have both APACHE and MIT. |
| 135 | if os.path.exists("LICENSE-APACHE"): |
| 136 | return "APACHE2" |
| 137 | if os.path.exists("LICENSE-MIT"): |
| 138 | return "MIT" |
| 139 | for license_file in ["LICENSE", "LICENSE.txt"]: |
| 140 | if os.path.exists(license_file): |
| 141 | return grep_license_keyword(license_file) |
| 142 | print("ERROR: missing LICENSE-{APACHE,MIT}; assume BSD_LIKE") |
| 143 | return "BSD_LIKE" |
| 144 | |
| 145 | |
| 146 | def add_notice(): |
| 147 | if not os.path.exists("NOTICE"): |
| 148 | if os.path.exists("LICENSE"): |
| 149 | os.symlink("LICENSE", "NOTICE") |
| 150 | print("Created link from NOTICE to LICENSE") |
| 151 | else: |
| 152 | print("ERROR: missing NOTICE and LICENSE") |
| 153 | |
| 154 | |
| 155 | def license_link_target(license_type): |
| 156 | """Return the LICENSE-* target file for LICENSE link.""" |
| 157 | if license_type == "APACHE2": |
| 158 | return "LICENSE-APACHE" |
| 159 | elif license_type == "MIT": |
| 160 | return "LICENSE-MIT" |
| 161 | elif license_type == "BSD_LIKE": |
| 162 | for name in ["LICENSE.txt"]: |
| 163 | if os.path.exists(name): |
| 164 | return name |
| 165 | print("### ERROR: cannot find LICENSE target") |
| 166 | return "" |
| 167 | else: |
| 168 | print("### ERROR; unknown license type:", license_type) |
| 169 | return "" |
| 170 | |
| 171 | |
| 172 | def check_license_link(license_type): |
| 173 | """Check the LICENSE link, must match given type.""" |
| 174 | if not os.path.islink("LICENSE"): |
| 175 | print("ERROR: LICENSE file is not a link") |
| 176 | return |
| 177 | target = os.readlink("LICENSE") |
| 178 | expected = license_link_target(license_type) |
| 179 | if target != expected: |
| 180 | print("ERROR: found LICENSE link to", target, |
| 181 | "but expected", expected) |
| 182 | |
| 183 | |
| 184 | def add_license(license_type): |
| 185 | """Add LICENSE related file.""" |
| 186 | if os.path.exists("LICENSE"): |
| 187 | if os.path.islink("LICENSE"): |
| 188 | check_license_link(license_type) |
| 189 | else: |
| 190 | print("NOTE: found LICENSE and it is not a link!") |
| 191 | return |
| 192 | target = license_link_target(license_type) |
| 193 | print("### Creating LICENSE link to", target) |
| 194 | if target: |
| 195 | os.symlink(target, "LICENSE") |
| 196 | # error reported in license_link_target |
| 197 | |
| 198 | |
| 199 | def add_module_license(license_type): |
| 200 | """Touch MODULE_LICENSE_type file.""" |
| 201 | # Do not change existing MODULE_* files. |
| 202 | for suffix in ["MIT", "APACHE", "APACHE2", "BSD_LIKE"]: |
| 203 | module_file = "MODULE_LICENSE_" + suffix |
| 204 | if os.path.exists(module_file): |
| 205 | if license_type != suffix: |
| 206 | print("### ERROR: found unexpected", module_file) |
| 207 | return |
| 208 | module_file = "MODULE_LICENSE_" + license_type |
| 209 | pathlib.Path(module_file).touch() |
| 210 | print("### Touched", module_file) |
| 211 | |
| 212 | |
| 213 | def found_line(file_name, line): |
| 214 | """Returns true if the given line is found in a file.""" |
| 215 | with open(file_name, "r") as input_file: |
| 216 | return line in input_file |
| 217 | |
| 218 | |
| 219 | def add_owners(): |
| 220 | """Create or append OWNERS with the default owner line.""" |
| 221 | # Existing OWNERS file might contain more than the default owners. |
| 222 | # Only append missing default owners to existing OWNERS. |
| 223 | if os.path.isfile("OWNERS"): |
| 224 | if found_line("OWNERS", DEFAULT_OWNERS): |
| 225 | print("### No change to OWNERS, which has already default owners.") |
| 226 | return |
| 227 | else: |
| 228 | print("### Append default owners to OWNERS") |
| 229 | mode = "a" |
| 230 | else: |
| 231 | print("### Creating OWNERS with default owners") |
| 232 | mode = "w" |
| 233 | with open("OWNERS", mode) as outf: |
| 234 | outf.write(DEFAULT_OWNERS) |
| 235 | |
| 236 | |
| 237 | def toml2json(line): |
| 238 | """Convert a quoted toml string to a json quoted string for METADATA.""" |
| 239 | if line.startswith("\"\"\""): |
| 240 | return "\"()\"" # cannot handle broken multi-line description |
| 241 | # TOML string escapes: \b \t \n \f \r \" \\ (no unicode escape) |
| 242 | line = line[1:-1].replace("\\\\", "\n").replace("\\b", "") |
| 243 | line = line.replace("\\t", " ").replace("\\n", " ").replace("\\f", " ") |
| 244 | line = line.replace("\\r", "").replace("\\\"", "\"").replace("\n", "\\") |
| 245 | # replace a unicode quotation mark, used in the libloading crate |
| 246 | line = line.replace("’", "'") |
| 247 | # strip and escape single quotes |
| 248 | return json.dumps(line.strip()).replace("'", "\\'") |
| 249 | |
| 250 | |
| 251 | def parse_cargo_toml(cargo): |
| 252 | """get description string from Cargo.toml.""" |
| 253 | name = "" |
| 254 | version = "" |
| 255 | description = "" |
| 256 | with open(cargo, "r") as toml: |
| 257 | for line in toml: |
| 258 | if not name: |
| 259 | match = NAME_MATCHER.match(line) |
| 260 | if match: |
| 261 | name = match.group(1) |
| 262 | if not version: |
| 263 | match = VERSION_MATCHER.match(line) |
| 264 | if match: |
| 265 | version = match.group(1) |
| 266 | if not description: |
| 267 | match = DESCRIPTION_MATCHER.match(line) |
| 268 | if match: |
| 269 | description = toml2json(match.group(1)) |
| 270 | if name and version and description: |
| 271 | break |
| 272 | return name, version, description |
| 273 | |
| 274 | |
| 275 | def main(): |
| 276 | """Add 3rd party review files.""" |
| 277 | cargo = "Cargo.toml" |
| 278 | if not os.path.isfile(cargo): |
| 279 | print("ERROR: ", cargo, "is not found") |
| 280 | return |
| 281 | if not os.access(cargo, os.R_OK): |
| 282 | print("ERROR: ", cargo, "is not readable") |
| 283 | return |
| 284 | name, version, description = parse_cargo_toml(cargo) |
| 285 | if not name or not version or not description: |
| 286 | print("ERROR: Cannot find name, version, or description in", cargo) |
| 287 | return |
| 288 | add_metadata(name, version, description) |
| 289 | add_owners() |
| 290 | license_type = decide_license_type() |
| 291 | add_license(license_type) |
| 292 | add_module_license(license_type) |
| 293 | # It is unclear yet if a NOTICE file is required. |
| 294 | # add_notice() |
| 295 | |
| 296 | |
| 297 | if __name__ == "__main__": |
| 298 | main() |