Chris Matthews | f0d9e88 | 2018-01-11 23:55:23 +0000 | [diff] [blame] | 1 | #!/usr/bin/python2.7 |
| 2 | """ |
| 3 | Dependency manager for llvm CI builds. |
| 4 | |
| 5 | We have complex dependencies for some of our CI builds. This will serve |
| 6 | as a system to help document and enforce them. |
| 7 | |
| 8 | Developer notes: |
| 9 | |
| 10 | - We are trying to keep package dependencies to a minimum in this project. So it |
| 11 | does not require an installer. It should be able to be run as a stand alone script |
| 12 | when checked out of VCS. So, don't import anything not in the Python 2.7 |
| 13 | standard library. |
| 14 | |
| 15 | """ |
| 16 | |
| 17 | from __future__ import absolute_import |
| 18 | from __future__ import division |
| 19 | from __future__ import print_function |
| 20 | from __future__ import unicode_literals |
| 21 | |
| 22 | import argparse |
Chris Matthews | ea57dc4 | 2018-01-31 22:37:34 +0000 | [diff] [blame] | 23 | import collections |
Chris Matthews | f0d9e88 | 2018-01-11 23:55:23 +0000 | [diff] [blame] | 24 | import json |
| 25 | import platform |
| 26 | import re |
| 27 | import subprocess |
| 28 | |
Chris Matthews | 5b8d4b2 | 2018-02-03 00:56:41 +0000 | [diff] [blame] | 29 | import os |
Chris Matthews | f0d9e88 | 2018-01-11 23:55:23 +0000 | [diff] [blame] | 30 | |
| 31 | try: |
Chris Matthews | bcc3c05 | 2018-02-01 00:33:15 +0000 | [diff] [blame] | 32 | # noinspection PyUnresolvedReferences |
| 33 | from typing import List, Text, Union, Dict, Type, Optional # noqa |
Chris Matthews | f0d9e88 | 2018-01-11 23:55:23 +0000 | [diff] [blame] | 34 | except ImportError as e: |
Chris Matthews | f0d9e88 | 2018-01-11 23:55:23 +0000 | [diff] [blame] | 35 | pass # Not really needed at runtime, so okay to not have installed. |
| 36 | |
| 37 | |
| 38 | VERSION = '0.1' |
| 39 | """We have a built in version check, so we can require specific features and fixes.""" |
| 40 | |
| 41 | |
| 42 | class Version(object): |
| 43 | """Model a version number, which can be compared to another version number. |
| 44 | |
| 45 | Keeps a nice looking text version around as well for printing. |
| 46 | |
| 47 | This abstraction exists to make some of the more complex comparisons easier, |
| 48 | as well as collecting and printing versions. |
| 49 | |
| 50 | In the future, we might want to have some different comparison, |
| 51 | for instance, 4.0 == 4.0.0 -> True. |
| 52 | |
| 53 | """ |
| 54 | |
| 55 | def __init__(self, text): |
| 56 | """Create a version from a . separated version string.""" |
| 57 | self.text = text |
| 58 | self.numeric = [int(d) for d in text.split(".")] |
| 59 | |
| 60 | def __gt__(self, other): |
| 61 | """Compare the numeric representation of the version.""" |
| 62 | return self.numeric.__gt__(other.numeric) |
| 63 | |
| 64 | def __lt__(self, other): |
| 65 | """Compare the numeric representation of the version.""" |
| 66 | return self.numeric.__lt__(other.numeric) |
| 67 | |
| 68 | def __eq__(self, other): |
| 69 | """Compare the numeric representation of the version.""" |
| 70 | return self.numeric.__eq__(other.numeric) |
| 71 | |
| 72 | def __le__(self, other): |
| 73 | """Compare the numeric representation of the version.""" |
| 74 | return self.numeric.__le__(other.numeric) |
| 75 | |
| 76 | def __ge__(self, other): |
| 77 | """Compare the numeric representation of the version.""" |
| 78 | return self.numeric.__ge__(other.numeric) |
| 79 | |
| 80 | def __repr__(self): |
| 81 | """Print the original text representation of the Version.""" |
| 82 | return "v{}".format(self.text) |
| 83 | |
| 84 | |
| 85 | class Dependency(object): |
| 86 | """Dependency Abstract base class.""" |
| 87 | |
| 88 | def __init__(self, line, str_kind): |
| 89 | """Save line information. |
| 90 | |
| 91 | :param line: A parsed Line object that contains the raw dependency deceleration. |
| 92 | :param str_kind: The determined kind of the Dependency. |
| 93 | """ |
| 94 | # type: (Line, Text) -> object |
| 95 | self.line = line |
| 96 | self.str_kind = str_kind |
| 97 | self.installed_version = None |
| 98 | |
| 99 | def parse(self): |
| 100 | """Read the input line and prepare to verify this dependency. |
| 101 | |
| 102 | Raise a MalformedDependencyError if three is something wrong. |
| 103 | |
| 104 | Should return nothing, but get the dependency ready for verification. |
| 105 | """ |
| 106 | raise NotImplementedError() |
| 107 | |
| 108 | def verify(self): |
| 109 | # type: () -> bool |
| 110 | """Determine if this dependency met. |
| 111 | |
| 112 | :returns: True when the dependency is met, otherwise False. |
| 113 | """ |
| 114 | raise NotImplementedError() |
| 115 | |
| 116 | def inject(self): |
| 117 | """If possible, modify the system to meet the dependency.""" |
| 118 | raise NotImplementedError() |
| 119 | |
| 120 | def verify_and_act(self): |
| 121 | """Parse, then verify and trigger pass or fail. |
| 122 | |
| 123 | Extract that out here, so we don't duplicate the logic in the subclasses. |
| 124 | """ |
| 125 | met = self.verify() |
| 126 | if met: |
| 127 | self.verify_pass() |
| 128 | else: |
| 129 | self.verify_fail() |
| 130 | |
| 131 | def verify_fail(self): |
| 132 | """When dependency is not met, raise an exception. |
| 133 | |
| 134 | This is the default behavior; but I want the subclasses to be able |
| 135 | to change it. |
| 136 | """ |
| 137 | raise MissingDependencyError(self, self.installed_version) |
| 138 | |
| 139 | def verify_pass(self): |
| 140 | """Print a nice message that the dependency is met. |
| 141 | |
| 142 | I'm not sure we even want to print this, but we will for now. It might |
| 143 | be to verbose. Subclasses should override this if wanted. |
| 144 | """ |
| 145 | print("Dependency met", str(self)) |
| 146 | |
| 147 | |
| 148 | class MalformedDependency(Exception): |
| 149 | """Raised when parsing a dependency directive fails. |
| 150 | |
| 151 | This is situations like the regexes not matching, or part of the dependency directive missing. |
| 152 | |
| 153 | Should probably record more useful stuff, but Exception.message is set. So we can print it later. |
| 154 | """ |
| 155 | |
| 156 | pass |
| 157 | |
| 158 | |
| 159 | def brew_cmd(command): |
Chris Matthews | bcc3c05 | 2018-02-01 00:33:15 +0000 | [diff] [blame] | 160 | # type: (List[Text]) -> List[Dict[Text, object]] |
Chris Matthews | f0d9e88 | 2018-01-11 23:55:23 +0000 | [diff] [blame] | 161 | """Help run a brew command, and parse the output. |
| 162 | |
| 163 | Brew has a json output option which we use. Run the command and parse the stdout |
| 164 | as json and return the result. |
| 165 | :param command: The brew command to execute, and parse the output of. |
| 166 | :return: |
| 167 | """ |
| 168 | assert "--json=v1" in command, "Must pass JSON arg so we can parse the output." |
| 169 | out = subprocess.check_output(command) |
| 170 | brew_info = json.loads(out) |
| 171 | return brew_info |
| 172 | |
| 173 | |
| 174 | class MissingDependencyError(Exception): |
| 175 | """Fail verification with one of these when we determine a dependency is not met. |
| 176 | |
| 177 | For each dependency, we will print a useful message with the dependency line as well as the |
| 178 | reason it was not matched. |
| 179 | """ |
| 180 | |
| 181 | def __init__(self, dependency, installed=None): |
| 182 | # type: (Dependency, Optional[Text]) -> None |
| 183 | """Raise when a dependency is not met. |
| 184 | |
| 185 | This exception can be printed as the error message. |
| 186 | |
| 187 | :param dependency: The dependency that is not being met. |
| 188 | :param installed: what was found to be installed instead. |
| 189 | """ |
| 190 | # type: (Dependency, Union[Text, Version]) -> None |
| 191 | super(MissingDependencyError, self).__init__() |
| 192 | self.dependency = dependency |
| 193 | self.installed = installed |
| 194 | |
| 195 | def __str__(self): |
| 196 | """For now, we will just print these as our error message.""" |
| 197 | return "missing dependency: {}, found {} installed, requested from {}".format(self.dependency, |
| 198 | self.installed, |
| 199 | self.dependency.line) |
| 200 | |
| 201 | |
| 202 | def check_version(installed, operator, requested): |
| 203 | """Check that the installed version does the operator of the requested. |
| 204 | |
| 205 | :param installed: The installed Version of the requirement. |
| 206 | :param operator: the text operator (==, <=, >=) |
| 207 | :param requested: The requested Version of the requirement. |
| 208 | :return: True if the requirement is satisfied. |
| 209 | """ |
| 210 | # type: (Version, Text, Version) -> bool |
| 211 | |
| 212 | dependency_met = False |
| 213 | if operator == "==" and installed == requested: |
| 214 | dependency_met = True |
| 215 | if operator == "<=" and installed <= requested: |
| 216 | dependency_met = True |
| 217 | if operator == ">=" and installed >= requested: |
| 218 | dependency_met = True |
| 219 | return dependency_met |
| 220 | |
| 221 | |
| 222 | class ConMan(Dependency): |
| 223 | """Version self-check of this tool. |
| 224 | |
| 225 | In case we introduce something in the future, the dep files can |
| 226 | be made to depend on a specific version of this tool. We will |
| 227 | increment the versions manually. |
| 228 | |
| 229 | """ |
| 230 | |
| 231 | conman_re = re.compile(r'(?P<command>\w+)\s+(?P<operator>>=|<=|==)\s*(?P<version_text>[\d.-_]+)') |
| 232 | """For example: config_manager <= 0.1""" |
| 233 | |
| 234 | def __init__(self, line, kind): |
| 235 | """Check that this tool is up to date.""" |
| 236 | super(ConMan, self).__init__(line, kind) |
| 237 | self.command = None |
| 238 | self.operator = None |
| 239 | self.version = None |
| 240 | self.version_text = None |
| 241 | self.installed_version = None |
| 242 | |
| 243 | def parse(self): |
| 244 | """Parse dependency.""" |
| 245 | text = self.line.text |
| 246 | match = self.conman_re.match(text) |
| 247 | if not match: |
| 248 | raise MalformedDependency("Expression does not compile in {}: {}".format(self.__class__.__name__, |
| 249 | self.line)) |
| 250 | self.__dict__.update(match.groupdict()) |
| 251 | |
| 252 | self.version = Version(self.version_text) |
| 253 | |
| 254 | def verify(self): |
| 255 | """Verify the version of this tool.""" |
| 256 | self.installed_version = Version(VERSION) |
| 257 | |
| 258 | return check_version(self.installed_version, self.operator, self.version) |
| 259 | |
| 260 | def inject(self): |
| 261 | """Can't really do much here.""" |
| 262 | pass |
| 263 | |
| 264 | def __str__(self): |
| 265 | """Show as dependency and version.""" |
| 266 | return "{} {}".format(self.str_kind, self.version) |
| 267 | |
| 268 | |
| 269 | class HostOSVersion(Dependency): |
| 270 | """Use Python's platform module to get host OS information and verify. |
| 271 | |
| 272 | Wew can only verify, but not inject for host OS version. |
| 273 | """ |
| 274 | |
| 275 | conman_re = re.compile(r'(?P<command>\w+)\s+(?P<operator>>=|<=|==)\s*(?P<version_text>[\d.-_]+)') |
| 276 | |
| 277 | def __init__(self, line, kind): |
| 278 | """Parse and Verify host OS version using Python's platform module. |
| 279 | |
| 280 | :param line: Line with teh Dependencies deceleration. |
| 281 | :param kind: the dependency kind that was detected by the parser. |
| 282 | """ |
| 283 | # type: (Line, Text) -> None |
| 284 | super(HostOSVersion, self).__init__(line, kind) |
| 285 | self.command = None |
| 286 | self.operator = None |
| 287 | self.version = None |
| 288 | self.version_text = None |
| 289 | self.installed_version = None |
| 290 | |
| 291 | def parse(self): |
| 292 | """Parse dependency.""" |
| 293 | text = self.line.text |
| 294 | match = self.conman_re.match(text) |
| 295 | if not match: |
| 296 | raise MalformedDependency("Expression does not compile in {}: {}".format(self.__class__.__name__, |
| 297 | self.line)) |
| 298 | self.__dict__.update(match.groupdict()) |
| 299 | |
| 300 | self.version = Version(self.version_text) |
| 301 | |
| 302 | def verify(self): |
| 303 | """Verify the request host OS version holds.""" |
| 304 | self.installed_version = Version(platform.mac_ver()[0]) |
| 305 | |
| 306 | return check_version(self.installed_version, self.operator, self.version) |
| 307 | |
| 308 | def inject(self): |
| 309 | """Can't change the host OS version, so not much to do here.""" |
| 310 | pass |
| 311 | |
| 312 | def __str__(self): |
| 313 | """For printing in error messages.""" |
| 314 | return "{} {}".format(self.str_kind, self.version) |
| 315 | |
| 316 | |
| 317 | class Brew(Dependency): |
| 318 | """Verify and Inject brew package dependencies.""" |
| 319 | |
| 320 | # brew <package> <operator> <version>. Operator may not have spaces around it. |
| 321 | brew_re = re.compile(r'(?P<command>\w+)\s+(?P<package>\w+)\s*(?P<operator>>=|<=|==)\s*(?P<version_text>[\d.-_]+)') |
| 322 | |
| 323 | def __init__(self, line, kind): |
| 324 | # type: (Line, Text) -> None |
| 325 | """Parse and verify brew package is installed. |
| 326 | |
| 327 | :param line: the Line with the deceleration of the dependency. |
| 328 | :param kind: the detected dependency kind. |
| 329 | """ |
| 330 | super(Brew, self).__init__(line, kind) |
| 331 | self.command = None |
| 332 | self.operator = None |
| 333 | self.package = None |
| 334 | self.version = None |
| 335 | self.version_text = None |
| 336 | self.installed_version = None |
| 337 | |
| 338 | def parse(self): |
| 339 | """Parse this dependency.""" |
| 340 | text = self.line.text |
| 341 | match = self.brew_re.match(text) |
| 342 | if not match: |
| 343 | raise MalformedDependency("Expression does not compile in {}: {}".format(self.__class__.__name__, |
| 344 | self.line)) |
| 345 | self.__dict__.update(match.groupdict()) |
| 346 | |
| 347 | self.version = Version(self.version_text) |
| 348 | |
| 349 | def verify(self): |
| 350 | """Verify the packages in brew match this dependency.""" |
Chris Matthews | 25d1da1 | 2018-02-01 01:21:23 +0000 | [diff] [blame] | 351 | try: |
| 352 | brew_package_config = brew_cmd(['/usr/local/bin/brew', 'info', self.package, "--json=v1"]) |
| 353 | except OSError: |
| 354 | raise MissingDependencyError(self, "Can't find brew command") |
Chris Matthews | f0d9e88 | 2018-01-11 23:55:23 +0000 | [diff] [blame] | 355 | version = None |
| 356 | for brew_package in brew_package_config: |
| 357 | name = brew_package['name'] |
Chris Matthews | cbaf8a8 | 2018-02-07 21:33:33 +0000 | [diff] [blame^] | 358 | linked_keg = brew_package["linked_keg"] |
| 359 | |
Chris Matthews | f0d9e88 | 2018-01-11 23:55:23 +0000 | [diff] [blame] | 360 | install_info = brew_package.get('installed') |
| 361 | for versions in install_info: |
Chris Matthews | cbaf8a8 | 2018-02-07 21:33:33 +0000 | [diff] [blame^] | 362 | if linked_keg == versions['version']: |
| 363 | version = versions['version'] |
Chris Matthews | f0d9e88 | 2018-01-11 23:55:23 +0000 | [diff] [blame] | 364 | if name == self.package: |
| 365 | break |
| 366 | if not version: |
| 367 | # The package is not installed at all. |
| 368 | raise MissingDependencyError(self, "nothing") |
| 369 | self.installed_version = Version(version) |
| 370 | return check_version(self.installed_version, self.operator, self.version) |
| 371 | |
| 372 | def inject(self): |
| 373 | """Not implemented.""" |
| 374 | raise NotImplementedError() |
| 375 | |
| 376 | def __str__(self): |
| 377 | """Dependency kind, package and version, for printing in error messages.""" |
| 378 | return "{} {} {}".format(self.str_kind, self.package, self.version) |
| 379 | |
| 380 | |
Chris Matthews | ea57dc4 | 2018-01-31 22:37:34 +0000 | [diff] [blame] | 381 | class Xcode(Dependency): |
| 382 | """Verify and Inject Xcode version dependencies.""" |
| 383 | |
| 384 | # xcode <operator> <version>. Operator may not have spaces around it. |
| 385 | xcode_dep_re = re.compile(r'(?P<command>\w+)\s+(?P<operator>>=|<=|==)\s*(?P<version_text>[\d.-_]+)') |
| 386 | |
| 387 | def __init__(self, line, kind): |
| 388 | # type: (Line, Text) -> None |
| 389 | """Parse and xcode version installed. |
| 390 | |
| 391 | :param line: the Line with the deceleration of the dependency. |
| 392 | :param kind: the detected dependency kind. |
| 393 | """ |
| 394 | super(Xcode, self).__init__(line, kind) |
| 395 | self.command = None |
| 396 | self.operator = None |
| 397 | self.version = None |
| 398 | self.version_text = None |
| 399 | self.installed_version = None |
| 400 | |
| 401 | def parse(self): |
| 402 | """Parse this dependency.""" |
| 403 | text = self.line.text |
| 404 | match = self.xcode_dep_re.match(text) |
| 405 | if not match: |
| 406 | raise MalformedDependency("Expression does not compile in {}: {}".format(self.__class__.__name__, |
| 407 | self.line)) |
| 408 | self.__dict__.update(match.groupdict()) |
| 409 | |
| 410 | self.version = Version(self.version_text) |
| 411 | |
| 412 | def verify(self): |
| 413 | """Verify the installed Xcode matches this dependency.""" |
| 414 | installed_version_output = subprocess.check_output(['/usr/bin/xcrun', 'xcodebuild', "-version"]) |
| 415 | installed_version_re = re.compile(r"^Xcode\s(?P<version_text>[\d/.]+)") |
| 416 | match = installed_version_re.match(installed_version_output) |
| 417 | if not match: |
| 418 | raise MissingDependencyError(self, "Did not find Xcode version in output:" + installed_version_output) |
| 419 | version = match.groupdict().get('version_text') |
| 420 | if not version: |
| 421 | # The package is not installed at all. |
| 422 | raise AssertionError("No version text found.") |
| 423 | self.installed_version = Version(version) |
| 424 | return check_version(self.installed_version, self.operator, self.version) |
| 425 | |
| 426 | def inject(self): |
| 427 | """Not implemented.""" |
| 428 | raise NotImplementedError() |
| 429 | |
| 430 | def __str__(self): |
| 431 | """Dependency kind, package and version, for printing in error messages.""" |
| 432 | return "{} {}".format(self.str_kind, self.version) |
| 433 | |
| 434 | |
| 435 | class Sdk(Dependency): |
| 436 | """Verify and Inject Sdk version dependencies.""" |
| 437 | |
| 438 | # sdk <operator> <version>. Operator may not have spaces around it. |
Chris Matthews | bcc3c05 | 2018-02-01 00:33:15 +0000 | [diff] [blame] | 439 | sdk_dep_re = re.compile(r'(?P<command>\w+)\s+(?P<sdk>[\w/.]+)\s*' |
| 440 | r'(?P<operator>>=|<=|==)\s*(?P<version_text>[\d.-_]+)') |
Chris Matthews | ea57dc4 | 2018-01-31 22:37:34 +0000 | [diff] [blame] | 441 | |
| 442 | def __init__(self, line, kind): |
| 443 | # type: (Line, Text) -> None |
| 444 | """Parse and sdk version installed. |
| 445 | |
| 446 | :param line: the Line with the deceleration of the dependency. |
| 447 | :param kind: the detected dependency kind. |
| 448 | """ |
| 449 | super(Sdk, self).__init__(line, kind) |
| 450 | self.command = None |
| 451 | self.sdk = None |
| 452 | self.operator = None |
| 453 | self.version = None |
| 454 | self.version_text = None |
| 455 | self.installed_version = None |
| 456 | |
| 457 | def parse(self): |
| 458 | """Parse this dependency.""" |
| 459 | text = self.line.text |
| 460 | match = self.sdk_dep_re.match(text) |
| 461 | if not match: |
| 462 | raise MalformedDependency("Expression does not compile in {}: {}".format(self.__class__.__name__, |
| 463 | self.line)) |
| 464 | self.__dict__.update(match.groupdict()) |
| 465 | |
| 466 | self.version = Version(self.version_text) |
| 467 | |
| 468 | def verify(self): |
| 469 | """Verify the installed Sdk matches this dependency.""" |
| 470 | installed_version_output = subprocess.check_output(['/usr/bin/xcrun', 'xcodebuild', "-showsdks"]) |
| 471 | installed_version_re = re.compile(r".*-sdk\s+(?P<sdk_text>\S+)") |
| 472 | |
| 473 | matches = [installed_version_re.match(l).groupdict()['sdk_text'] |
| 474 | for l in installed_version_output.split('\n') if installed_version_re.match(l)] |
| 475 | |
| 476 | if not matches: |
| 477 | raise MissingDependencyError(self, "Did not find Sdk version in output:" + installed_version_output) |
| 478 | |
| 479 | extract_version_names = re.compile(r'(?P<pre>\D*)(?P<version_text>[\d+/.]*)(?P<post>.*)') |
| 480 | |
| 481 | sdks = [extract_version_names.match(sdk_text).groupdict() |
| 482 | for sdk_text in matches if extract_version_names.match(sdk_text)] |
| 483 | |
| 484 | installed_sdks = collections.defaultdict(list) |
| 485 | for sdk in sdks: |
| 486 | name = sdk['pre'] |
| 487 | if sdk.get('post'): |
| 488 | name += "." + sdk.get('post') |
| 489 | if sdk.get('version_text'): |
| 490 | version = Version(sdk['version_text'].rstrip('.')) |
| 491 | else: |
| 492 | continue |
| 493 | installed_sdks[name].append(version) |
| 494 | |
| 495 | if self.sdk not in installed_sdks.keys(): |
Chris Matthews | bcc3c05 | 2018-02-01 00:33:15 +0000 | [diff] [blame] | 496 | raise MissingDependencyError(self, "{} not found in installed SDKs.".format(self.sdk)) |
Chris Matthews | ea57dc4 | 2018-01-31 22:37:34 +0000 | [diff] [blame] | 497 | |
| 498 | self.installed_version = installed_sdks[self.sdk] |
| 499 | |
| 500 | satisfied = [check_version(s, self.operator, self.version) for s in self.installed_version] |
| 501 | return any(satisfied) |
| 502 | |
| 503 | def inject(self): |
| 504 | """Not implemented.""" |
| 505 | raise NotImplementedError() |
| 506 | |
| 507 | def __str__(self): |
| 508 | """Dependency kind, package and version, for printing in error messages.""" |
| 509 | return "{} {}".format(self.str_kind, self.version) |
| 510 | |
| 511 | |
Chris Matthews | 6daa080 | 2018-02-01 00:33:16 +0000 | [diff] [blame] | 512 | class Pip(Dependency): |
| 513 | """Verify and Inject pip package dependencies.""" |
| 514 | |
| 515 | # pip <package> <operator> <version>. Operator may not have spaces around it. |
| 516 | pip_re = re.compile(r'(?P<command>\w+)\s+(?P<package>\w+)\s*(?P<operator>>=|<=|==)\s*(?P<version_text>[\d.-_]+)') |
| 517 | |
| 518 | def __init__(self, line, kind): |
| 519 | # type: (Line, Text) -> None |
| 520 | """Parse and verify pip package is installed. |
| 521 | |
| 522 | :param line: the Line with the deceleration of the dependency. |
| 523 | :param kind: the detected dependency kind. |
| 524 | """ |
| 525 | super(Pip, self).__init__(line, kind) |
| 526 | self.command = None |
| 527 | self.operator = None |
| 528 | self.package = None |
| 529 | self.version = None |
| 530 | self.version_text = None |
| 531 | self.installed_version = None |
| 532 | |
| 533 | def parse(self): |
| 534 | """Parse this dependency.""" |
| 535 | text = self.line.text |
| 536 | match = self.pip_re.match(text) |
| 537 | if not match: |
| 538 | raise MalformedDependency("Expression does not compile in {}: {}".format(self.__class__.__name__, |
| 539 | self.line)) |
| 540 | self.__dict__.update(match.groupdict()) |
| 541 | |
| 542 | self.version = Version(self.version_text) |
| 543 | |
| 544 | def verify(self): |
| 545 | """Verify the packages in pip match this dependency.""" |
Chris Matthews | a6b8348 | 2018-02-02 21:24:00 +0000 | [diff] [blame] | 546 | |
Chris Matthews | 6daa080 | 2018-02-01 00:33:16 +0000 | [diff] [blame] | 547 | try: |
Chris Matthews | a6b8348 | 2018-02-02 21:24:00 +0000 | [diff] [blame] | 548 | pip_version = subprocess.check_output(["/usr/bin/env", "python", "-m", "pip", "--version"]) |
| 549 | pip_tokens = pip_version.split() |
| 550 | assert pip_tokens[0] == "pip" |
| 551 | pip_version = Version(pip_tokens[1]) |
| 552 | |
| 553 | if pip_version < Version("9.0.0"): |
| 554 | raise MissingDependencyError("Version of pip too old.") |
| 555 | |
Chris Matthews | 6daa080 | 2018-02-01 00:33:16 +0000 | [diff] [blame] | 556 | pip_package_config = json.loads(subprocess.check_output(["/usr/bin/env", |
| 557 | "python", "-m", "pip", "list", "--format=json"])) |
| 558 | except (subprocess.CalledProcessError, OSError): |
| 559 | raise MissingDependencyError(self, "Cannot find pip") |
| 560 | |
| 561 | installed = {p['name']: p['version'] for p in pip_package_config} # type: Dict[Text, Text] |
| 562 | |
| 563 | package = installed.get(self.package) |
| 564 | |
| 565 | if not package: |
| 566 | # The package is not installed at all. |
| 567 | raise MissingDependencyError(self, "not in package list") |
| 568 | self.installed_version = Version(package) |
| 569 | return check_version(self.installed_version, self.operator, self.version) |
| 570 | |
| 571 | def inject(self): |
| 572 | """Not implemented.""" |
| 573 | raise NotImplementedError() |
| 574 | |
| 575 | def __str__(self): |
| 576 | """Dependency kind, package and version, for printing in error messages.""" |
| 577 | return "{} {} {}".format(self.str_kind, self.package, self.version) |
| 578 | |
| 579 | |
Chris Matthews | f0d9e88 | 2018-01-11 23:55:23 +0000 | [diff] [blame] | 580 | dependencies_implementations = {'brew': Brew, |
| 581 | 'os_version': HostOSVersion, |
Chris Matthews | ea57dc4 | 2018-01-31 22:37:34 +0000 | [diff] [blame] | 582 | 'config_manager': ConMan, |
| 583 | 'xcode': Xcode, |
| 584 | 'sdk': Sdk, |
Chris Matthews | 6daa080 | 2018-02-01 00:33:16 +0000 | [diff] [blame] | 585 | 'pip': Pip, |
Chris Matthews | ea57dc4 | 2018-01-31 22:37:34 +0000 | [diff] [blame] | 586 | } |
Chris Matthews | f0d9e88 | 2018-01-11 23:55:23 +0000 | [diff] [blame] | 587 | |
| 588 | |
| 589 | def dependency_factory(line): |
| 590 | """Given a line, create a concrete dependency for it. |
| 591 | |
| 592 | :param line: The line with the dependency info |
| 593 | :return: Some subclass of Dependency, based on what was in the line. |
| 594 | """ |
| 595 | # type: Text -> Dependency |
| 596 | kind = line.text.split()[0] |
| 597 | try: |
| 598 | return dependencies_implementations[kind](line, kind) |
| 599 | except KeyError: |
| 600 | raise MalformedDependency("Don't know about {} kind of dependency.".format(kind)) |
| 601 | |
| 602 | |
| 603 | class Line(object): |
| 604 | """A preprocessed line. Understands file and line number as well as comments.""" |
| 605 | |
| 606 | def __init__(self, filename, line_number, text, comment): |
| 607 | # type: (Text, int, Text, Text) -> None |
| 608 | """Raw Line information, split into the dependency deceleration and comment. |
| 609 | |
| 610 | :param filename: the input filename. |
| 611 | :param line_number: the line number in the input file. |
| 612 | :param text: Non-comment part of the line. |
| 613 | :param comment: Text from the comment part of the line if any. |
| 614 | """ |
| 615 | self.filename = filename |
| 616 | self.line_number = line_number |
| 617 | self.text = text |
| 618 | self.comment = comment |
| 619 | |
| 620 | def __repr__(self): |
| 621 | """Reconstruct the line for pretty printing.""" |
Chris Matthews | 5b8d4b2 | 2018-02-03 00:56:41 +0000 | [diff] [blame] | 622 | return "{}:{}: {}{}".format(os.path.basename(self.filename), |
Chris Matthews | f0d9e88 | 2018-01-11 23:55:23 +0000 | [diff] [blame] | 623 | self.line_number, |
| 624 | self.text, |
| 625 | " # " + self.comment if self.comment else "") |
| 626 | |
| 627 | |
| 628 | # For stripping comments out of lines. |
| 629 | comment_re = re.compile(r'(?P<main_text>.*)#(?P<comment>.*)') |
| 630 | |
| 631 | |
| 632 | # noinspection PyUnresolvedReferences |
| 633 | def _parse_dep_file(lines, filename): |
| 634 | # type: (List[Text], Text) -> List[Line] |
| 635 | process_lines = [] |
| 636 | for num, text in enumerate(lines): |
| 637 | if "#" in text: |
| 638 | bits = comment_re.match(text) |
| 639 | main_text = bits.groupdict().get('main_text') |
| 640 | comment = bits.groupdict().get('comment') |
| 641 | else: |
| 642 | main_text = text |
| 643 | comment = None |
| 644 | if main_text: |
| 645 | main_text = main_text.strip() |
| 646 | if comment: |
| 647 | comment = comment.strip() |
| 648 | process_lines.append(Line(filename, num, main_text, comment)) |
| 649 | |
| 650 | return process_lines |
| 651 | |
| 652 | |
| 653 | def parse_dependencies(file_names): |
| 654 | """Program logic: read files, verify dependencies. |
| 655 | |
| 656 | For each input file, read lines and create dependencies. Verify each dependency. |
| 657 | |
| 658 | :param file_names: files to read dependencies from. |
| 659 | :return: The list of dependencies, each verified. |
| 660 | """ |
| 661 | # type: (List[Text]) -> List[Type[Dependency]] |
| 662 | preprocessed_lines = [] |
| 663 | for file_name in file_names: |
| 664 | with open(file_name, 'r') as f: |
| 665 | lines = f.readlines() |
| 666 | preprocessed_lines.extend(_parse_dep_file(lines, file_name)) |
| 667 | |
| 668 | dependencies = [dependency_factory(l) for l in preprocessed_lines if l.text] |
| 669 | [d.parse() for d in dependencies] |
| 670 | for d in dependencies: |
| 671 | try: |
| 672 | met = d.verify() |
| 673 | if met: |
| 674 | d.verify_pass() |
| 675 | else: |
| 676 | d.verify_fail() |
| 677 | |
| 678 | except MissingDependencyError as exec_info: |
| 679 | print("Error:", exec_info) |
| 680 | |
| 681 | return dependencies |
| 682 | |
| 683 | |
| 684 | def main(): |
| 685 | """Parse arguments and trigger dependency verification.""" |
| 686 | parser = argparse.ArgumentParser(description='Verify and install dependencies.') |
| 687 | parser.add_argument('command', help="What to do.") |
| 688 | |
| 689 | parser.add_argument('dependencies', nargs='+', help="Path to dependency files.") |
| 690 | |
| 691 | args = parser.parse_args() |
| 692 | |
Chris Matthews | bd9d9bb | 2018-02-06 19:00:40 +0000 | [diff] [blame] | 693 | full_file_paths = [os.path.abspath(path) for path in args.dependencies] |
| 694 | |
| 695 | parse_dependencies(full_file_paths) |
Chris Matthews | f0d9e88 | 2018-01-11 23:55:23 +0000 | [diff] [blame] | 696 | |
| 697 | return True |
| 698 | |
| 699 | |
| 700 | if __name__ == '__main__': |
| 701 | main() |