blob: 6b308e6d31dc12019a29820b689d660dfa7bfd45 [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
23import json
24import platform
25import re
26import subprocess
27
28
29try:
30 from typing import List, Text, Union, Dict, Type, Optional
31except ImportError as e:
32 Optional = Type = Dict = List = Text = Union = None
33 pass # Not really needed at runtime, so okay to not have installed.
34
35
36VERSION = '0.1'
37"""We have a built in version check, so we can require specific features and fixes."""
38
39
40class Version(object):
41 """Model a version number, which can be compared to another version number.
42
43 Keeps a nice looking text version around as well for printing.
44
45 This abstraction exists to make some of the more complex comparisons easier,
46 as well as collecting and printing versions.
47
48 In the future, we might want to have some different comparison,
49 for instance, 4.0 == 4.0.0 -> True.
50
51 """
52
53 def __init__(self, text):
54 """Create a version from a . separated version string."""
55 self.text = text
56 self.numeric = [int(d) for d in text.split(".")]
57
58 def __gt__(self, other):
59 """Compare the numeric representation of the version."""
60 return self.numeric.__gt__(other.numeric)
61
62 def __lt__(self, other):
63 """Compare the numeric representation of the version."""
64 return self.numeric.__lt__(other.numeric)
65
66 def __eq__(self, other):
67 """Compare the numeric representation of the version."""
68 return self.numeric.__eq__(other.numeric)
69
70 def __le__(self, other):
71 """Compare the numeric representation of the version."""
72 return self.numeric.__le__(other.numeric)
73
74 def __ge__(self, other):
75 """Compare the numeric representation of the version."""
76 return self.numeric.__ge__(other.numeric)
77
78 def __repr__(self):
79 """Print the original text representation of the Version."""
80 return "v{}".format(self.text)
81
82
83class Dependency(object):
84 """Dependency Abstract base class."""
85
86 def __init__(self, line, str_kind):
87 """Save line information.
88
89 :param line: A parsed Line object that contains the raw dependency deceleration.
90 :param str_kind: The determined kind of the Dependency.
91 """
92 # type: (Line, Text) -> object
93 self.line = line
94 self.str_kind = str_kind
95 self.installed_version = None
96
97 def parse(self):
98 """Read the input line and prepare to verify this dependency.
99
100 Raise a MalformedDependencyError if three is something wrong.
101
102 Should return nothing, but get the dependency ready for verification.
103 """
104 raise NotImplementedError()
105
106 def verify(self):
107 # type: () -> bool
108 """Determine if this dependency met.
109
110 :returns: True when the dependency is met, otherwise False.
111 """
112 raise NotImplementedError()
113
114 def inject(self):
115 """If possible, modify the system to meet the dependency."""
116 raise NotImplementedError()
117
118 def verify_and_act(self):
119 """Parse, then verify and trigger pass or fail.
120
121 Extract that out here, so we don't duplicate the logic in the subclasses.
122 """
123 met = self.verify()
124 if met:
125 self.verify_pass()
126 else:
127 self.verify_fail()
128
129 def verify_fail(self):
130 """When dependency is not met, raise an exception.
131
132 This is the default behavior; but I want the subclasses to be able
133 to change it.
134 """
135 raise MissingDependencyError(self, self.installed_version)
136
137 def verify_pass(self):
138 """Print a nice message that the dependency is met.
139
140 I'm not sure we even want to print this, but we will for now. It might
141 be to verbose. Subclasses should override this if wanted.
142 """
143 print("Dependency met", str(self))
144
145
146class MalformedDependency(Exception):
147 """Raised when parsing a dependency directive fails.
148
149 This is situations like the regexes not matching, or part of the dependency directive missing.
150
151 Should probably record more useful stuff, but Exception.message is set. So we can print it later.
152 """
153
154 pass
155
156
157def brew_cmd(command):
158 # type: (List[Text]) -> Dict[Text, object]
159 """Help run a brew command, and parse the output.
160
161 Brew has a json output option which we use. Run the command and parse the stdout
162 as json and return the result.
163 :param command: The brew command to execute, and parse the output of.
164 :return:
165 """
166 assert "--json=v1" in command, "Must pass JSON arg so we can parse the output."
167 out = subprocess.check_output(command)
168 brew_info = json.loads(out)
169 return brew_info
170
171
172class MissingDependencyError(Exception):
173 """Fail verification with one of these when we determine a dependency is not met.
174
175 For each dependency, we will print a useful message with the dependency line as well as the
176 reason it was not matched.
177 """
178
179 def __init__(self, dependency, installed=None):
180 # type: (Dependency, Optional[Text]) -> None
181 """Raise when a dependency is not met.
182
183 This exception can be printed as the error message.
184
185 :param dependency: The dependency that is not being met.
186 :param installed: what was found to be installed instead.
187 """
188 # type: (Dependency, Union[Text, Version]) -> None
189 super(MissingDependencyError, self).__init__()
190 self.dependency = dependency
191 self.installed = installed
192
193 def __str__(self):
194 """For now, we will just print these as our error message."""
195 return "missing dependency: {}, found {} installed, requested from {}".format(self.dependency,
196 self.installed,
197 self.dependency.line)
198
199
200def check_version(installed, operator, requested):
201 """Check that the installed version does the operator of the requested.
202
203 :param installed: The installed Version of the requirement.
204 :param operator: the text operator (==, <=, >=)
205 :param requested: The requested Version of the requirement.
206 :return: True if the requirement is satisfied.
207 """
208 # type: (Version, Text, Version) -> bool
209
210 dependency_met = False
211 if operator == "==" and installed == requested:
212 dependency_met = True
213 if operator == "<=" and installed <= requested:
214 dependency_met = True
215 if operator == ">=" and installed >= requested:
216 dependency_met = True
217 return dependency_met
218
219
220class ConMan(Dependency):
221 """Version self-check of this tool.
222
223 In case we introduce something in the future, the dep files can
224 be made to depend on a specific version of this tool. We will
225 increment the versions manually.
226
227 """
228
229 conman_re = re.compile(r'(?P<command>\w+)\s+(?P<operator>>=|<=|==)\s*(?P<version_text>[\d.-_]+)')
230 """For example: config_manager <= 0.1"""
231
232 def __init__(self, line, kind):
233 """Check that this tool is up to date."""
234 super(ConMan, self).__init__(line, kind)
235 self.command = None
236 self.operator = None
237 self.version = None
238 self.version_text = None
239 self.installed_version = None
240
241 def parse(self):
242 """Parse dependency."""
243 text = self.line.text
244 match = self.conman_re.match(text)
245 if not match:
246 raise MalformedDependency("Expression does not compile in {}: {}".format(self.__class__.__name__,
247 self.line))
248 self.__dict__.update(match.groupdict())
249
250 self.version = Version(self.version_text)
251
252 def verify(self):
253 """Verify the version of this tool."""
254 self.installed_version = Version(VERSION)
255
256 return check_version(self.installed_version, self.operator, self.version)
257
258 def inject(self):
259 """Can't really do much here."""
260 pass
261
262 def __str__(self):
263 """Show as dependency and version."""
264 return "{} {}".format(self.str_kind, self.version)
265
266
267class HostOSVersion(Dependency):
268 """Use Python's platform module to get host OS information and verify.
269
270 Wew can only verify, but not inject for host OS version.
271 """
272
273 conman_re = re.compile(r'(?P<command>\w+)\s+(?P<operator>>=|<=|==)\s*(?P<version_text>[\d.-_]+)')
274
275 def __init__(self, line, kind):
276 """Parse and Verify host OS version using Python's platform module.
277
278 :param line: Line with teh Dependencies deceleration.
279 :param kind: the dependency kind that was detected by the parser.
280 """
281 # type: (Line, Text) -> None
282 super(HostOSVersion, self).__init__(line, kind)
283 self.command = None
284 self.operator = None
285 self.version = None
286 self.version_text = None
287 self.installed_version = None
288
289 def parse(self):
290 """Parse dependency."""
291 text = self.line.text
292 match = self.conman_re.match(text)
293 if not match:
294 raise MalformedDependency("Expression does not compile in {}: {}".format(self.__class__.__name__,
295 self.line))
296 self.__dict__.update(match.groupdict())
297
298 self.version = Version(self.version_text)
299
300 def verify(self):
301 """Verify the request host OS version holds."""
302 self.installed_version = Version(platform.mac_ver()[0])
303
304 return check_version(self.installed_version, self.operator, self.version)
305
306 def inject(self):
307 """Can't change the host OS version, so not much to do here."""
308 pass
309
310 def __str__(self):
311 """For printing in error messages."""
312 return "{} {}".format(self.str_kind, self.version)
313
314
315class Brew(Dependency):
316 """Verify and Inject brew package dependencies."""
317
318 # brew <package> <operator> <version>. Operator may not have spaces around it.
319 brew_re = re.compile(r'(?P<command>\w+)\s+(?P<package>\w+)\s*(?P<operator>>=|<=|==)\s*(?P<version_text>[\d.-_]+)')
320
321 def __init__(self, line, kind):
322 # type: (Line, Text) -> None
323 """Parse and verify brew package is installed.
324
325 :param line: the Line with the deceleration of the dependency.
326 :param kind: the detected dependency kind.
327 """
328 super(Brew, self).__init__(line, kind)
329 self.command = None
330 self.operator = None
331 self.package = None
332 self.version = None
333 self.version_text = None
334 self.installed_version = None
335
336 def parse(self):
337 """Parse this dependency."""
338 text = self.line.text
339 match = self.brew_re.match(text)
340 if not match:
341 raise MalformedDependency("Expression does not compile in {}: {}".format(self.__class__.__name__,
342 self.line))
343 self.__dict__.update(match.groupdict())
344
345 self.version = Version(self.version_text)
346
347 def verify(self):
348 """Verify the packages in brew match this dependency."""
349 brew_package_config = brew_cmd(['/usr/local/bin/brew', 'info', self.package, "--json=v1"])
350 version = None
351 for brew_package in brew_package_config:
352 name = brew_package['name']
353 install_info = brew_package.get('installed')
354 for versions in install_info:
355 version = versions['version'] if versions else None
356 if name == self.package:
357 break
358 if not version:
359 # The package is not installed at all.
360 raise MissingDependencyError(self, "nothing")
361 self.installed_version = Version(version)
362 return check_version(self.installed_version, self.operator, self.version)
363
364 def inject(self):
365 """Not implemented."""
366 raise NotImplementedError()
367
368 def __str__(self):
369 """Dependency kind, package and version, for printing in error messages."""
370 return "{} {} {}".format(self.str_kind, self.package, self.version)
371
372
373dependencies_implementations = {'brew': Brew,
374 'os_version': HostOSVersion,
375 'config_manager': ConMan}
376
377
378def dependency_factory(line):
379 """Given a line, create a concrete dependency for it.
380
381 :param line: The line with the dependency info
382 :return: Some subclass of Dependency, based on what was in the line.
383 """
384 # type: Text -> Dependency
385 kind = line.text.split()[0]
386 try:
387 return dependencies_implementations[kind](line, kind)
388 except KeyError:
389 raise MalformedDependency("Don't know about {} kind of dependency.".format(kind))
390
391
392class Line(object):
393 """A preprocessed line. Understands file and line number as well as comments."""
394
395 def __init__(self, filename, line_number, text, comment):
396 # type: (Text, int, Text, Text) -> None
397 """Raw Line information, split into the dependency deceleration and comment.
398
399 :param filename: the input filename.
400 :param line_number: the line number in the input file.
401 :param text: Non-comment part of the line.
402 :param comment: Text from the comment part of the line if any.
403 """
404 self.filename = filename
405 self.line_number = line_number
406 self.text = text
407 self.comment = comment
408
409 def __repr__(self):
410 """Reconstruct the line for pretty printing."""
411 return "{}:{}: {}{}".format(self.filename,
412 self.line_number,
413 self.text,
414 " # " + self.comment if self.comment else "")
415
416
417# For stripping comments out of lines.
418comment_re = re.compile(r'(?P<main_text>.*)#(?P<comment>.*)')
419
420
421# noinspection PyUnresolvedReferences
422def _parse_dep_file(lines, filename):
423 # type: (List[Text], Text) -> List[Line]
424 process_lines = []
425 for num, text in enumerate(lines):
426 if "#" in text:
427 bits = comment_re.match(text)
428 main_text = bits.groupdict().get('main_text')
429 comment = bits.groupdict().get('comment')
430 else:
431 main_text = text
432 comment = None
433 if main_text:
434 main_text = main_text.strip()
435 if comment:
436 comment = comment.strip()
437 process_lines.append(Line(filename, num, main_text, comment))
438
439 return process_lines
440
441
442def parse_dependencies(file_names):
443 """Program logic: read files, verify dependencies.
444
445 For each input file, read lines and create dependencies. Verify each dependency.
446
447 :param file_names: files to read dependencies from.
448 :return: The list of dependencies, each verified.
449 """
450 # type: (List[Text]) -> List[Type[Dependency]]
451 preprocessed_lines = []
452 for file_name in file_names:
453 with open(file_name, 'r') as f:
454 lines = f.readlines()
455 preprocessed_lines.extend(_parse_dep_file(lines, file_name))
456
457 dependencies = [dependency_factory(l) for l in preprocessed_lines if l.text]
458 [d.parse() for d in dependencies]
459 for d in dependencies:
460 try:
461 met = d.verify()
462 if met:
463 d.verify_pass()
464 else:
465 d.verify_fail()
466
467 except MissingDependencyError as exec_info:
468 print("Error:", exec_info)
469
470 return dependencies
471
472
473def main():
474 """Parse arguments and trigger dependency verification."""
475 parser = argparse.ArgumentParser(description='Verify and install dependencies.')
476 parser.add_argument('command', help="What to do.")
477
478 parser.add_argument('dependencies', nargs='+', help="Path to dependency files.")
479
480 args = parser.parse_args()
481
482 parse_dependencies(args.dependencies)
483
484 return True
485
486
487if __name__ == '__main__':
488 main()