blob: 6ccd4b44d67be0b8b1fb8f785b5a83adb0e35e8a [file] [log] [blame]
Chris Matthewsf0d9e882018-01-11 23:55:23 +00001#!/usr/bin/python2.7
2"""
3Dependency manager for llvm CI builds.
4
5We have complex dependencies for some of our CI builds. This will serve
6as a system to help document and enforce them.
7
8Developer notes:
9
10- We are trying to keep package dependencies to a minimum in this project. So it
11does not require an installer. It should be able to be run as a stand alone script
12when checked out of VCS. So, don't import anything not in the Python 2.7
13standard library.
14
15"""
16
17from __future__ import absolute_import
18from __future__ import division
19from __future__ import print_function
20from __future__ import unicode_literals
21
22import argparse
Chris Matthewsea57dc42018-01-31 22:37:34 +000023import collections
Chris Matthewsf0d9e882018-01-11 23:55:23 +000024import json
25import platform
26import re
27import subprocess
28
Chris Matthews5b8d4b22018-02-03 00:56:41 +000029import os
Chris Matthewsf0d9e882018-01-11 23:55:23 +000030
31try:
Chris Matthewsbcc3c052018-02-01 00:33:15 +000032 # noinspection PyUnresolvedReferences
33 from typing import List, Text, Union, Dict, Type, Optional # noqa
Chris Matthewsf0d9e882018-01-11 23:55:23 +000034except ImportError as e:
Chris Matthewsf0d9e882018-01-11 23:55:23 +000035 pass # Not really needed at runtime, so okay to not have installed.
36
37
38VERSION = '0.1'
39"""We have a built in version check, so we can require specific features and fixes."""
40
41
42class 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
85class 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
148class 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
159def brew_cmd(command):
Chris Matthewsbcc3c052018-02-01 00:33:15 +0000160 # type: (List[Text]) -> List[Dict[Text, object]]
Chris Matthewsf0d9e882018-01-11 23:55:23 +0000161 """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
174class 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
202def 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
222class 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
269class 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
317class 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 Matthews25d1da12018-02-01 01:21:23 +0000351 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 Matthewsf0d9e882018-01-11 23:55:23 +0000355 version = None
356 for brew_package in brew_package_config:
357 name = brew_package['name']
Chris Matthewscbaf8a82018-02-07 21:33:33 +0000358 linked_keg = brew_package["linked_keg"]
359
Chris Matthewsf0d9e882018-01-11 23:55:23 +0000360 install_info = brew_package.get('installed')
361 for versions in install_info:
Chris Matthewscbaf8a82018-02-07 21:33:33 +0000362 if linked_keg == versions['version']:
363 version = versions['version']
Chris Matthewsf0d9e882018-01-11 23:55:23 +0000364 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 Matthewsea57dc42018-01-31 22:37:34 +0000381class 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
435class Sdk(Dependency):
436 """Verify and Inject Sdk version dependencies."""
437
438 # sdk <operator> <version>. Operator may not have spaces around it.
Chris Matthewsbcc3c052018-02-01 00:33:15 +0000439 sdk_dep_re = re.compile(r'(?P<command>\w+)\s+(?P<sdk>[\w/.]+)\s*'
440 r'(?P<operator>>=|<=|==)\s*(?P<version_text>[\d.-_]+)')
Chris Matthewsea57dc42018-01-31 22:37:34 +0000441
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 Matthewsbcc3c052018-02-01 00:33:15 +0000496 raise MissingDependencyError(self, "{} not found in installed SDKs.".format(self.sdk))
Chris Matthewsea57dc42018-01-31 22:37:34 +0000497
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 Matthews6daa0802018-02-01 00:33:16 +0000512class 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 Matthewsa6b83482018-02-02 21:24:00 +0000546
Chris Matthews6daa0802018-02-01 00:33:16 +0000547 try:
Chris Matthewsa6b83482018-02-02 21:24:00 +0000548 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 Matthews6daa0802018-02-01 00:33:16 +0000556 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 Matthewsf0d9e882018-01-11 23:55:23 +0000580dependencies_implementations = {'brew': Brew,
581 'os_version': HostOSVersion,
Chris Matthewsea57dc42018-01-31 22:37:34 +0000582 'config_manager': ConMan,
583 'xcode': Xcode,
584 'sdk': Sdk,
Chris Matthews6daa0802018-02-01 00:33:16 +0000585 'pip': Pip,
Chris Matthewsea57dc42018-01-31 22:37:34 +0000586 }
Chris Matthewsf0d9e882018-01-11 23:55:23 +0000587
588
589def 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
603class 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 Matthews5b8d4b22018-02-03 00:56:41 +0000622 return "{}:{}: {}{}".format(os.path.basename(self.filename),
Chris Matthewsf0d9e882018-01-11 23:55:23 +0000623 self.line_number,
624 self.text,
625 " # " + self.comment if self.comment else "")
626
627
628# For stripping comments out of lines.
629comment_re = re.compile(r'(?P<main_text>.*)#(?P<comment>.*)')
630
631
632# noinspection PyUnresolvedReferences
633def _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
653def 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
684def 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 Matthewsbd9d9bb2018-02-06 19:00:40 +0000693 full_file_paths = [os.path.abspath(path) for path in args.dependencies]
694
695 parse_dependencies(full_file_paths)
Chris Matthewsf0d9e882018-01-11 23:55:23 +0000696
697 return True
698
699
700if __name__ == '__main__':
701 main()