blob: e927d4c0c8e9cece66eb44a152b5a7f18d1f6fa9 [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']
358 install_info = brew_package.get('installed')
359 for versions in install_info:
360 version = versions['version'] if versions else None
361 if name == self.package:
362 break
363 if not version:
364 # The package is not installed at all.
365 raise MissingDependencyError(self, "nothing")
366 self.installed_version = Version(version)
367 return check_version(self.installed_version, self.operator, self.version)
368
369 def inject(self):
370 """Not implemented."""
371 raise NotImplementedError()
372
373 def __str__(self):
374 """Dependency kind, package and version, for printing in error messages."""
375 return "{} {} {}".format(self.str_kind, self.package, self.version)
376
377
Chris Matthewsea57dc42018-01-31 22:37:34 +0000378class Xcode(Dependency):
379 """Verify and Inject Xcode version dependencies."""
380
381 # xcode <operator> <version>. Operator may not have spaces around it.
382 xcode_dep_re = re.compile(r'(?P<command>\w+)\s+(?P<operator>>=|<=|==)\s*(?P<version_text>[\d.-_]+)')
383
384 def __init__(self, line, kind):
385 # type: (Line, Text) -> None
386 """Parse and xcode version installed.
387
388 :param line: the Line with the deceleration of the dependency.
389 :param kind: the detected dependency kind.
390 """
391 super(Xcode, self).__init__(line, kind)
392 self.command = None
393 self.operator = None
394 self.version = None
395 self.version_text = None
396 self.installed_version = None
397
398 def parse(self):
399 """Parse this dependency."""
400 text = self.line.text
401 match = self.xcode_dep_re.match(text)
402 if not match:
403 raise MalformedDependency("Expression does not compile in {}: {}".format(self.__class__.__name__,
404 self.line))
405 self.__dict__.update(match.groupdict())
406
407 self.version = Version(self.version_text)
408
409 def verify(self):
410 """Verify the installed Xcode matches this dependency."""
411 installed_version_output = subprocess.check_output(['/usr/bin/xcrun', 'xcodebuild', "-version"])
412 installed_version_re = re.compile(r"^Xcode\s(?P<version_text>[\d/.]+)")
413 match = installed_version_re.match(installed_version_output)
414 if not match:
415 raise MissingDependencyError(self, "Did not find Xcode version in output:" + installed_version_output)
416 version = match.groupdict().get('version_text')
417 if not version:
418 # The package is not installed at all.
419 raise AssertionError("No version text found.")
420 self.installed_version = Version(version)
421 return check_version(self.installed_version, self.operator, self.version)
422
423 def inject(self):
424 """Not implemented."""
425 raise NotImplementedError()
426
427 def __str__(self):
428 """Dependency kind, package and version, for printing in error messages."""
429 return "{} {}".format(self.str_kind, self.version)
430
431
432class Sdk(Dependency):
433 """Verify and Inject Sdk version dependencies."""
434
435 # sdk <operator> <version>. Operator may not have spaces around it.
Chris Matthewsbcc3c052018-02-01 00:33:15 +0000436 sdk_dep_re = re.compile(r'(?P<command>\w+)\s+(?P<sdk>[\w/.]+)\s*'
437 r'(?P<operator>>=|<=|==)\s*(?P<version_text>[\d.-_]+)')
Chris Matthewsea57dc42018-01-31 22:37:34 +0000438
439 def __init__(self, line, kind):
440 # type: (Line, Text) -> None
441 """Parse and sdk version installed.
442
443 :param line: the Line with the deceleration of the dependency.
444 :param kind: the detected dependency kind.
445 """
446 super(Sdk, self).__init__(line, kind)
447 self.command = None
448 self.sdk = None
449 self.operator = None
450 self.version = None
451 self.version_text = None
452 self.installed_version = None
453
454 def parse(self):
455 """Parse this dependency."""
456 text = self.line.text
457 match = self.sdk_dep_re.match(text)
458 if not match:
459 raise MalformedDependency("Expression does not compile in {}: {}".format(self.__class__.__name__,
460 self.line))
461 self.__dict__.update(match.groupdict())
462
463 self.version = Version(self.version_text)
464
465 def verify(self):
466 """Verify the installed Sdk matches this dependency."""
467 installed_version_output = subprocess.check_output(['/usr/bin/xcrun', 'xcodebuild', "-showsdks"])
468 installed_version_re = re.compile(r".*-sdk\s+(?P<sdk_text>\S+)")
469
470 matches = [installed_version_re.match(l).groupdict()['sdk_text']
471 for l in installed_version_output.split('\n') if installed_version_re.match(l)]
472
473 if not matches:
474 raise MissingDependencyError(self, "Did not find Sdk version in output:" + installed_version_output)
475
476 extract_version_names = re.compile(r'(?P<pre>\D*)(?P<version_text>[\d+/.]*)(?P<post>.*)')
477
478 sdks = [extract_version_names.match(sdk_text).groupdict()
479 for sdk_text in matches if extract_version_names.match(sdk_text)]
480
481 installed_sdks = collections.defaultdict(list)
482 for sdk in sdks:
483 name = sdk['pre']
484 if sdk.get('post'):
485 name += "." + sdk.get('post')
486 if sdk.get('version_text'):
487 version = Version(sdk['version_text'].rstrip('.'))
488 else:
489 continue
490 installed_sdks[name].append(version)
491
492 if self.sdk not in installed_sdks.keys():
Chris Matthewsbcc3c052018-02-01 00:33:15 +0000493 raise MissingDependencyError(self, "{} not found in installed SDKs.".format(self.sdk))
Chris Matthewsea57dc42018-01-31 22:37:34 +0000494
495 self.installed_version = installed_sdks[self.sdk]
496
497 satisfied = [check_version(s, self.operator, self.version) for s in self.installed_version]
498 return any(satisfied)
499
500 def inject(self):
501 """Not implemented."""
502 raise NotImplementedError()
503
504 def __str__(self):
505 """Dependency kind, package and version, for printing in error messages."""
506 return "{} {}".format(self.str_kind, self.version)
507
508
Chris Matthews6daa0802018-02-01 00:33:16 +0000509class Pip(Dependency):
510 """Verify and Inject pip package dependencies."""
511
512 # pip <package> <operator> <version>. Operator may not have spaces around it.
513 pip_re = re.compile(r'(?P<command>\w+)\s+(?P<package>\w+)\s*(?P<operator>>=|<=|==)\s*(?P<version_text>[\d.-_]+)')
514
515 def __init__(self, line, kind):
516 # type: (Line, Text) -> None
517 """Parse and verify pip package is installed.
518
519 :param line: the Line with the deceleration of the dependency.
520 :param kind: the detected dependency kind.
521 """
522 super(Pip, self).__init__(line, kind)
523 self.command = None
524 self.operator = None
525 self.package = None
526 self.version = None
527 self.version_text = None
528 self.installed_version = None
529
530 def parse(self):
531 """Parse this dependency."""
532 text = self.line.text
533 match = self.pip_re.match(text)
534 if not match:
535 raise MalformedDependency("Expression does not compile in {}: {}".format(self.__class__.__name__,
536 self.line))
537 self.__dict__.update(match.groupdict())
538
539 self.version = Version(self.version_text)
540
541 def verify(self):
542 """Verify the packages in pip match this dependency."""
Chris Matthewsa6b83482018-02-02 21:24:00 +0000543
Chris Matthews6daa0802018-02-01 00:33:16 +0000544 try:
Chris Matthewsa6b83482018-02-02 21:24:00 +0000545 pip_version = subprocess.check_output(["/usr/bin/env", "python", "-m", "pip", "--version"])
546 pip_tokens = pip_version.split()
547 assert pip_tokens[0] == "pip"
548 pip_version = Version(pip_tokens[1])
549
550 if pip_version < Version("9.0.0"):
551 raise MissingDependencyError("Version of pip too old.")
552
Chris Matthews6daa0802018-02-01 00:33:16 +0000553 pip_package_config = json.loads(subprocess.check_output(["/usr/bin/env",
554 "python", "-m", "pip", "list", "--format=json"]))
555 except (subprocess.CalledProcessError, OSError):
556 raise MissingDependencyError(self, "Cannot find pip")
557
558 installed = {p['name']: p['version'] for p in pip_package_config} # type: Dict[Text, Text]
559
560 package = installed.get(self.package)
561
562 if not package:
563 # The package is not installed at all.
564 raise MissingDependencyError(self, "not in package list")
565 self.installed_version = Version(package)
566 return check_version(self.installed_version, self.operator, self.version)
567
568 def inject(self):
569 """Not implemented."""
570 raise NotImplementedError()
571
572 def __str__(self):
573 """Dependency kind, package and version, for printing in error messages."""
574 return "{} {} {}".format(self.str_kind, self.package, self.version)
575
576
Chris Matthewsf0d9e882018-01-11 23:55:23 +0000577dependencies_implementations = {'brew': Brew,
578 'os_version': HostOSVersion,
Chris Matthewsea57dc42018-01-31 22:37:34 +0000579 'config_manager': ConMan,
580 'xcode': Xcode,
581 'sdk': Sdk,
Chris Matthews6daa0802018-02-01 00:33:16 +0000582 'pip': Pip,
Chris Matthewsea57dc42018-01-31 22:37:34 +0000583 }
Chris Matthewsf0d9e882018-01-11 23:55:23 +0000584
585
586def dependency_factory(line):
587 """Given a line, create a concrete dependency for it.
588
589 :param line: The line with the dependency info
590 :return: Some subclass of Dependency, based on what was in the line.
591 """
592 # type: Text -> Dependency
593 kind = line.text.split()[0]
594 try:
595 return dependencies_implementations[kind](line, kind)
596 except KeyError:
597 raise MalformedDependency("Don't know about {} kind of dependency.".format(kind))
598
599
600class Line(object):
601 """A preprocessed line. Understands file and line number as well as comments."""
602
603 def __init__(self, filename, line_number, text, comment):
604 # type: (Text, int, Text, Text) -> None
605 """Raw Line information, split into the dependency deceleration and comment.
606
607 :param filename: the input filename.
608 :param line_number: the line number in the input file.
609 :param text: Non-comment part of the line.
610 :param comment: Text from the comment part of the line if any.
611 """
612 self.filename = filename
613 self.line_number = line_number
614 self.text = text
615 self.comment = comment
616
617 def __repr__(self):
618 """Reconstruct the line for pretty printing."""
Chris Matthews5b8d4b22018-02-03 00:56:41 +0000619 return "{}:{}: {}{}".format(os.path.basename(self.filename),
Chris Matthewsf0d9e882018-01-11 23:55:23 +0000620 self.line_number,
621 self.text,
622 " # " + self.comment if self.comment else "")
623
624
625# For stripping comments out of lines.
626comment_re = re.compile(r'(?P<main_text>.*)#(?P<comment>.*)')
627
628
629# noinspection PyUnresolvedReferences
630def _parse_dep_file(lines, filename):
631 # type: (List[Text], Text) -> List[Line]
632 process_lines = []
633 for num, text in enumerate(lines):
634 if "#" in text:
635 bits = comment_re.match(text)
636 main_text = bits.groupdict().get('main_text')
637 comment = bits.groupdict().get('comment')
638 else:
639 main_text = text
640 comment = None
641 if main_text:
642 main_text = main_text.strip()
643 if comment:
644 comment = comment.strip()
645 process_lines.append(Line(filename, num, main_text, comment))
646
647 return process_lines
648
649
650def parse_dependencies(file_names):
651 """Program logic: read files, verify dependencies.
652
653 For each input file, read lines and create dependencies. Verify each dependency.
654
655 :param file_names: files to read dependencies from.
656 :return: The list of dependencies, each verified.
657 """
658 # type: (List[Text]) -> List[Type[Dependency]]
659 preprocessed_lines = []
660 for file_name in file_names:
661 with open(file_name, 'r') as f:
662 lines = f.readlines()
663 preprocessed_lines.extend(_parse_dep_file(lines, file_name))
664
665 dependencies = [dependency_factory(l) for l in preprocessed_lines if l.text]
666 [d.parse() for d in dependencies]
667 for d in dependencies:
668 try:
669 met = d.verify()
670 if met:
671 d.verify_pass()
672 else:
673 d.verify_fail()
674
675 except MissingDependencyError as exec_info:
676 print("Error:", exec_info)
677
678 return dependencies
679
680
681def main():
682 """Parse arguments and trigger dependency verification."""
683 parser = argparse.ArgumentParser(description='Verify and install dependencies.')
684 parser.add_argument('command', help="What to do.")
685
686 parser.add_argument('dependencies', nargs='+', help="Path to dependency files.")
687
688 args = parser.parse_args()
689
690 parse_dependencies(args.dependencies)
691
692 return True
693
694
695if __name__ == '__main__':
696 main()