1#!/usr/bin/env python
2# encoding: utf-8
3# Laurent Birtz, 2011
4# moved the code into a separate tool (ita)
5
6"""
7There are several things here:
8- a different command-line option management making options persistent
9- the review command to display the options set
10
11Assumptions:
12- configuration options are not always added to the right group (and do not count on the users to do it...)
13- the options are persistent between the executions (waf options are NOT persistent by design), even for the configuration
14- when the options change, the build is invalidated (forcing a reconfiguration)
15"""
16
17import os, textwrap, shutil
18from waflib import Logs, Context, ConfigSet, Options, Build, Configure
19
20class Odict(dict):
21	"""Ordered dictionary"""
22	def __init__(self, data=None):
23		self._keys = []
24		dict.__init__(self)
25		if data:
26			# we were provided a regular dict
27			if isinstance(data, dict):
28				self.append_from_dict(data)
29
30			# we were provided a tuple list
31			elif type(data) == list:
32				self.append_from_plist(data)
33
34			# we were provided invalid input
35			else:
36				raise Exception("expected a dict or a tuple list")
37
38	def append_from_dict(self, dict):
39		map(self.__setitem__, dict.keys(), dict.values())
40
41	def append_from_plist(self, plist):
42		for pair in plist:
43			if len(pair) != 2:
44				raise Exception("invalid pairs list")
45		for (k, v) in plist:
46			self.__setitem__(k, v)
47
48	def __delitem__(self, key):
49		if not key in self._keys:
50			raise KeyError(key)
51		dict.__delitem__(self, key)
52		self._keys.remove(key)
53
54	def __setitem__(self, key, item):
55		dict.__setitem__(self, key, item)
56		if key not in self._keys:
57			self._keys.append(key)
58
59	def clear(self):
60		dict.clear(self)
61		self._keys = []
62
63	def copy(self):
64		return Odict(self.plist())
65
66	def items(self):
67		return zip(self._keys, self.values())
68
69	def keys(self):
70		return list(self._keys) # return a copy of the list
71
72	def values(self):
73		return map(self.get, self._keys)
74
75	def plist(self):
76		p = []
77		for k, v in self.items():
78			p.append( (k, v) )
79		return p
80
81	def __str__(self):
82		buf = []
83		buf.append("{ ")
84		for k, v in self.items():
85			buf.append('%r : %r, ' % (k, v))
86		buf.append("}")
87		return ''.join(buf)
88
89review_options = Odict()
90"""
91Ordered dictionary mapping configuration option names to their optparse option.
92"""
93
94review_defaults = {}
95"""
96Dictionary mapping configuration option names to their default value.
97"""
98
99old_review_set = None
100"""
101Review set containing the configuration values before parsing the command line.
102"""
103
104new_review_set = None
105"""
106Review set containing the configuration values after parsing the command line.
107"""
108
109class OptionsReview(Options.OptionsContext):
110	def __init__(self, **kw):
111		super(self.__class__, self).__init__(**kw)
112
113	def prepare_config_review(self):
114		"""
115		Find the configuration options that are reviewable, detach
116		their default value from their optparse object and store them
117		into the review dictionaries.
118		"""
119		gr = self.get_option_group('configure options')
120		for opt in gr.option_list:
121			if opt.action != 'store' or opt.dest in ("out", "top"):
122				continue
123			review_options[opt.dest] = opt
124			review_defaults[opt.dest] = opt.default
125			if gr.defaults.has_key(opt.dest):
126				del gr.defaults[opt.dest]
127			opt.default = None
128
129	def parse_args(self):
130		self.prepare_config_review()
131		self.parser.get_option('--prefix').help = 'installation prefix'
132		super(OptionsReview, self).parse_args()
133		Context.create_context('review').refresh_review_set()
134
135class ReviewContext(Context.Context):
136	'''reviews the configuration values'''
137
138	cmd = 'review'
139
140	def __init__(self, **kw):
141		super(self.__class__, self).__init__(**kw)
142
143		out = Options.options.out
144		if not out:
145			out = getattr(Context.g_module, Context.OUT, None)
146		if not out:
147			out = Options.lockfile.replace('.lock-waf', '')
148		self.build_path = (os.path.isabs(out) and self.root or self.path).make_node(out).abspath()
149		"""Path to the build directory"""
150
151		self.cache_path = os.path.join(self.build_path, Build.CACHE_DIR)
152		"""Path to the cache directory"""
153
154		self.review_path = os.path.join(self.cache_path, 'review.cache')
155		"""Path to the review cache file"""
156
157	def execute(self):
158		"""
159		Display and store the review set. Invalidate the cache as required.
160		"""
161		if not self.compare_review_set(old_review_set, new_review_set):
162			self.invalidate_cache()
163		self.store_review_set(new_review_set)
164		print(self.display_review_set(new_review_set))
165
166	def invalidate_cache(self):
167		"""Invalidate the cache to prevent bad builds."""
168		try:
169			Logs.warn("Removing the cached configuration since the options have changed")
170			shutil.rmtree(self.cache_path)
171		except:
172			pass
173
174	def refresh_review_set(self):
175		"""
176		Obtain the old review set and the new review set, and import the new set.
177		"""
178		global old_review_set, new_review_set
179		old_review_set = self.load_review_set()
180		new_review_set = self.update_review_set(old_review_set)
181		self.import_review_set(new_review_set)
182
183	def load_review_set(self):
184		"""
185		Load and return the review set from the cache if it exists.
186		Otherwise, return an empty set.
187		"""
188		if os.path.isfile(self.review_path):
189			return ConfigSet.ConfigSet(self.review_path)
190		return ConfigSet.ConfigSet()
191
192	def store_review_set(self, review_set):
193		"""
194		Store the review set specified in the cache.
195		"""
196		if not os.path.isdir(self.cache_path):
197			os.makedirs(self.cache_path)
198		review_set.store(self.review_path)
199
200	def update_review_set(self, old_set):
201		"""
202		Merge the options passed on the command line with those imported
203		from the previous review set and return the corresponding
204		preview set.
205		"""
206
207		# Convert value to string. It's important that 'None' maps to
208		# the empty string.
209		def val_to_str(val):
210			if val == None or val == '':
211				return ''
212			return str(val)
213
214		new_set = ConfigSet.ConfigSet()
215		opt_dict = Options.options.__dict__
216
217		for name in review_options.keys():
218			# the option is specified explicitly on the command line
219			if name in opt_dict:
220				# if the option is the default, pretend it was never specified
221				if val_to_str(opt_dict[name]) != val_to_str(review_defaults[name]):
222					new_set[name] = opt_dict[name]
223			# the option was explicitly specified in a previous command
224			elif name in old_set:
225				new_set[name] = old_set[name]
226
227		return new_set
228
229	def import_review_set(self, review_set):
230		"""
231		Import the actual value of the reviewable options in the option
232		dictionary, given the current review set.
233		"""
234		for name in review_options.keys():
235			if name in review_set:
236				value = review_set[name]
237			else:
238				value = review_defaults[name]
239			setattr(Options.options, name, value)
240
241	def compare_review_set(self, set1, set2):
242		"""
243		Return true if the review sets specified are equal.
244		"""
245		if len(set1.keys()) != len(set2.keys()):
246			return False
247		for key in set1.keys():
248			if not key in set2 or set1[key] != set2[key]:
249				return False
250		return True
251
252	def display_review_set(self, review_set):
253		"""
254		Return the string representing the review set specified.
255		"""
256		term_width = Logs.get_term_cols()
257		lines = []
258		for dest in review_options.keys():
259			opt = review_options[dest]
260			name = ", ".join(opt._short_opts + opt._long_opts)
261			help = opt.help
262			actual = None
263			if dest in review_set:
264				actual = review_set[dest]
265			default = review_defaults[dest]
266			lines.append(self.format_option(name, help, actual, default, term_width))
267		return "Configuration:\n\n" + "\n\n".join(lines) + "\n"
268
269	def format_option(self, name, help, actual, default, term_width):
270		"""
271		Return the string representing the option specified.
272		"""
273		def val_to_str(val):
274			if val == None or val == '':
275				return "(void)"
276			return str(val)
277
278		max_name_len = 20
279		sep_len = 2
280
281		w = textwrap.TextWrapper()
282		w.width = term_width - 1
283		if w.width < 60:
284			w.width = 60
285
286		out = ""
287
288		# format the help
289		out += w.fill(help) + "\n"
290
291		# format the name
292		name_len = len(name)
293		out += Logs.colors.CYAN + name + Logs.colors.NORMAL
294
295		# set the indentation used when the value wraps to the next line
296		w.subsequent_indent = " ".rjust(max_name_len + sep_len)
297		w.width -= (max_name_len + sep_len)
298
299		# the name string is too long, switch to the next line
300		if name_len > max_name_len:
301			out += "\n" + w.subsequent_indent
302
303		# fill the remaining of the line with spaces
304		else:
305			out += " ".rjust(max_name_len + sep_len - name_len)
306
307		# format the actual value, if there is one
308		if actual != None:
309			out += Logs.colors.BOLD + w.fill(val_to_str(actual)) + Logs.colors.NORMAL + "\n" + w.subsequent_indent
310
311		# format the default value
312		default_fmt = val_to_str(default)
313		if actual != None:
314			default_fmt = "default: " + default_fmt
315		out += Logs.colors.NORMAL + w.fill(default_fmt) + Logs.colors.NORMAL
316
317		return out
318
319# Monkey-patch ConfigurationContext.execute() to have it store the review set.
320old_configure_execute = Configure.ConfigurationContext.execute
321def new_configure_execute(self):
322	old_configure_execute(self)
323	Context.create_context('review').store_review_set(new_review_set)
324Configure.ConfigurationContext.execute = new_configure_execute
325
326