blob: 95def41449de87e02b4b84d77ae20f944e1b2a86 [file] [log] [blame]
Marc-Antoine Ruela5a36222014-01-09 10:35:45 -05001# Copyright 2014 The Swarming Authors. All rights reserved.
2# Use of this source code is governed under the Apache License, Version 2.0 that
3# can be found in the LICENSE file.
4
5"""Contains logic to parse .isolate files.
6
7This module doesn't touch the file system. It's the job of the client code to do
8I/O on behalf of this module.
9
10See more information at
11 https://code.google.com/p/swarming/wiki/IsolateDesign
12 https://code.google.com/p/swarming/wiki/IsolateUserGuide
13"""
14
15import ast
16import copy
17import itertools
18import logging
19import os
20import re
21
22import isolateserver
23
24from utils import short_expression_finder
25
26# Files that should be 0-length when mapped.
27KEY_TOUCHED = 'isolate_dependency_touched'
28# Files that should be tracked by the build tool.
29KEY_TRACKED = 'isolate_dependency_tracked'
30# Files that should not be tracked by the build tool.
31KEY_UNTRACKED = 'isolate_dependency_untracked'
32
33# Valid variable name.
34VALID_VARIABLE = '[A-Za-z_][A-Za-z_0-9]*'
35
36
37def determine_root_dir(relative_root, infiles):
38 """For a list of infiles, determines the deepest root directory that is
39 referenced indirectly.
40
41 All arguments must be using os.path.sep.
42 """
43 # The trick used to determine the root directory is to look at "how far" back
44 # up it is looking up.
45 deepest_root = relative_root
46 for i in infiles:
47 x = relative_root
48 while i.startswith('..' + os.path.sep):
49 i = i[3:]
50 assert not i.startswith(os.path.sep)
51 x = os.path.dirname(x)
52 if deepest_root.startswith(x):
53 deepest_root = x
54 logging.debug(
55 'determine_root_dir(%s, %d files) -> %s' % (
56 relative_root, len(infiles), deepest_root))
57 return deepest_root
58
59
60def replace_variable(part, variables):
61 m = re.match(r'<\((' + VALID_VARIABLE + ')\)', part)
62 if m:
63 if m.group(1) not in variables:
64 raise isolateserver.ConfigError(
65 'Variable "%s" was not found in %s.\nDid you forget to specify '
66 '--path-variable?' % (m.group(1), variables))
67 return variables[m.group(1)]
68 return part
69
70
71def eval_variables(item, variables):
72 """Replaces the .isolate variables in a string item.
73
74 Note that the .isolate format is a subset of the .gyp dialect.
75 """
76 return ''.join(
77 replace_variable(p, variables)
78 for p in re.split(r'(<\(' + VALID_VARIABLE + '\))', item))
79
80
81def split_touched(files):
82 """Splits files that are touched vs files that are read."""
83 tracked = []
84 touched = []
85 for f in files:
86 if f.size:
87 tracked.append(f)
88 else:
89 touched.append(f)
90 return tracked, touched
91
92
93def pretty_print(variables, stdout):
94 """Outputs a gyp compatible list from the decoded variables.
95
96 Similar to pprint.print() but with NIH syndrome.
97 """
98 # Order the dictionary keys by these keys in priority.
99 ORDER = (
100 'variables', 'condition', 'command', 'relative_cwd', 'read_only',
101 KEY_TRACKED, KEY_UNTRACKED)
102
103 def sorting_key(x):
104 """Gives priority to 'most important' keys before the others."""
105 if x in ORDER:
106 return str(ORDER.index(x))
107 return x
108
109 def loop_list(indent, items):
110 for item in items:
111 if isinstance(item, basestring):
112 stdout.write('%s\'%s\',\n' % (indent, item))
113 elif isinstance(item, dict):
114 stdout.write('%s{\n' % indent)
115 loop_dict(indent + ' ', item)
116 stdout.write('%s},\n' % indent)
117 elif isinstance(item, list):
118 # A list inside a list will write the first item embedded.
119 stdout.write('%s[' % indent)
120 for index, i in enumerate(item):
121 if isinstance(i, basestring):
122 stdout.write(
123 '\'%s\', ' % i.replace('\\', '\\\\').replace('\'', '\\\''))
124 elif isinstance(i, dict):
125 stdout.write('{\n')
126 loop_dict(indent + ' ', i)
127 if index != len(item) - 1:
128 x = ', '
129 else:
130 x = ''
131 stdout.write('%s}%s' % (indent, x))
132 else:
133 assert False
134 stdout.write('],\n')
135 else:
136 assert False
137
138 def loop_dict(indent, items):
139 for key in sorted(items, key=sorting_key):
140 item = items[key]
141 stdout.write("%s'%s': " % (indent, key))
142 if isinstance(item, dict):
143 stdout.write('{\n')
144 loop_dict(indent + ' ', item)
145 stdout.write(indent + '},\n')
146 elif isinstance(item, list):
147 stdout.write('[\n')
148 loop_list(indent + ' ', item)
149 stdout.write(indent + '],\n')
150 elif isinstance(item, basestring):
151 stdout.write(
152 '\'%s\',\n' % item.replace('\\', '\\\\').replace('\'', '\\\''))
Marc-Antoine Ruel7124e392014-01-09 11:49:21 -0500153 elif isinstance(item, (int, bool)) or item is None:
Marc-Antoine Ruela5a36222014-01-09 10:35:45 -0500154 stdout.write('%s\n' % item)
155 else:
156 assert False, item
157
158 stdout.write('{\n')
159 loop_dict(' ', variables)
160 stdout.write('}\n')
161
162
163def print_all(comment, data, stream):
164 """Prints a complete .isolate file and its top-level file comment into a
165 stream.
166 """
167 if comment:
168 stream.write(comment)
169 pretty_print(data, stream)
170
171
172def union(lhs, rhs):
173 """Merges two compatible datastructures composed of dict/list/set."""
174 assert lhs is not None or rhs is not None
175 if lhs is None:
176 return copy.deepcopy(rhs)
177 if rhs is None:
178 return copy.deepcopy(lhs)
179 assert type(lhs) == type(rhs), (lhs, rhs)
180 if hasattr(lhs, 'union'):
181 # Includes set, ConfigSettings and Configs.
182 return lhs.union(rhs)
183 if isinstance(lhs, dict):
184 return dict((k, union(lhs.get(k), rhs.get(k))) for k in set(lhs).union(rhs))
185 elif isinstance(lhs, list):
186 # Do not go inside the list.
187 return lhs + rhs
188 assert False, type(lhs)
189
190
191def extract_comment(content):
192 """Extracts file level comment."""
193 out = []
194 for line in content.splitlines(True):
195 if line.startswith('#'):
196 out.append(line)
197 else:
198 break
199 return ''.join(out)
200
201
202def eval_content(content):
203 """Evaluates a python file and return the value defined in it.
204
205 Used in practice for .isolate files.
206 """
207 globs = {'__builtins__': None}
208 locs = {}
209 try:
210 value = eval(content, globs, locs)
211 except TypeError as e:
212 e.args = list(e.args) + [content]
213 raise
214 assert locs == {}, locs
215 assert globs == {'__builtins__': None}, globs
216 return value
217
218
219def match_configs(expr, config_variables, all_configs):
220 """Returns the configs from |all_configs| that match the |expr|, where
221 the elements of |all_configs| are tuples of values for the |config_variables|.
222 Example:
223 >>> match_configs(expr = "(foo==1 or foo==2) and bar=='b'",
224 config_variables = ["foo", "bar"],
225 all_configs = [(1, 'a'), (1, 'b'), (2, 'a'), (2, 'b')])
226 [(1, 'b'), (2, 'b')]
227 """
228 return [
229 config for config in all_configs
230 if eval(expr, dict(zip(config_variables, config)))
231 ]
232
233
234def verify_variables(variables):
235 """Verifies the |variables| dictionary is in the expected format."""
236 VALID_VARIABLES = [
237 KEY_TOUCHED,
238 KEY_TRACKED,
239 KEY_UNTRACKED,
240 'command',
241 'read_only',
242 ]
243 assert isinstance(variables, dict), variables
244 assert set(VALID_VARIABLES).issuperset(set(variables)), variables.keys()
245 for name, value in variables.iteritems():
246 if name == 'read_only':
Marc-Antoine Ruel7124e392014-01-09 11:49:21 -0500247 assert value in (0, 1, 2, None), value
Marc-Antoine Ruela5a36222014-01-09 10:35:45 -0500248 else:
249 assert isinstance(value, list), value
250 assert all(isinstance(i, basestring) for i in value), value
251
252
253def verify_ast(expr, variables_and_values):
254 """Verifies that |expr| is of the form
255 expr ::= expr ( "or" | "and" ) expr
256 | identifier "==" ( string | int )
257 Also collects the variable identifiers and string/int values in the dict
258 |variables_and_values|, in the form {'var': set([val1, val2, ...]), ...}.
259 """
260 assert isinstance(expr, (ast.BoolOp, ast.Compare))
261 if isinstance(expr, ast.BoolOp):
262 assert isinstance(expr.op, (ast.And, ast.Or))
263 for subexpr in expr.values:
264 verify_ast(subexpr, variables_and_values)
265 else:
266 assert isinstance(expr.left.ctx, ast.Load)
267 assert len(expr.ops) == 1
268 assert isinstance(expr.ops[0], ast.Eq)
269 var_values = variables_and_values.setdefault(expr.left.id, set())
270 rhs = expr.comparators[0]
271 assert isinstance(rhs, (ast.Str, ast.Num))
272 var_values.add(rhs.n if isinstance(rhs, ast.Num) else rhs.s)
273
274
275def verify_condition(condition, variables_and_values):
276 """Verifies the |condition| dictionary is in the expected format.
277 See verify_ast() for the meaning of |variables_and_values|.
278 """
279 VALID_INSIDE_CONDITION = ['variables']
280 assert isinstance(condition, list), condition
281 assert len(condition) == 2, condition
282 expr, then = condition
283
284 test_ast = compile(expr, '<condition>', 'eval', ast.PyCF_ONLY_AST)
285 verify_ast(test_ast.body, variables_and_values)
286
287 assert isinstance(then, dict), then
288 assert set(VALID_INSIDE_CONDITION).issuperset(set(then)), then.keys()
289 if not 'variables' in then:
290 raise isolateserver.ConfigError('Missing \'variables\' in condition %s' %
291 condition)
292 verify_variables(then['variables'])
293
294
295def verify_root(value, variables_and_values):
296 """Verifies that |value| is the parsed form of a valid .isolate file.
297 See verify_ast() for the meaning of |variables_and_values|.
298 """
299 VALID_ROOTS = ['includes', 'conditions']
300 assert isinstance(value, dict), value
301 assert set(VALID_ROOTS).issuperset(set(value)), value.keys()
302
303 includes = value.get('includes', [])
304 assert isinstance(includes, list), includes
305 for include in includes:
306 assert isinstance(include, basestring), include
307
308 conditions = value.get('conditions', [])
309 assert isinstance(conditions, list), conditions
310 for condition in conditions:
311 verify_condition(condition, variables_and_values)
312
313
314def remove_weak_dependencies(values, key, item, item_configs):
315 """Removes any configs from this key if the item is already under a
316 strong key.
317 """
318 if key == KEY_TOUCHED:
319 item_configs = set(item_configs)
320 for stronger_key in (KEY_TRACKED, KEY_UNTRACKED):
321 try:
322 item_configs -= values[stronger_key][item]
323 except KeyError:
324 pass
325
326 return item_configs
327
328
329def remove_repeated_dependencies(folders, key, item, item_configs):
330 """Removes any configs from this key if the item is in a folder that is
331 already included."""
332
333 if key in (KEY_UNTRACKED, KEY_TRACKED, KEY_TOUCHED):
334 item_configs = set(item_configs)
335 for (folder, configs) in folders.iteritems():
336 if folder != item and item.startswith(folder):
337 item_configs -= configs
338
339 return item_configs
340
341
342def get_folders(values_dict):
343 """Returns a dict of all the folders in the given value_dict."""
344 return dict(
345 (item, configs) for (item, configs) in values_dict.iteritems()
346 if item.endswith('/')
347 )
348
349
350def invert_map(variables):
351 """Converts {config: {deptype: list(depvals)}} to
352 {deptype: {depval: set(configs)}}.
353 """
354 KEYS = (
355 KEY_TOUCHED,
356 KEY_TRACKED,
357 KEY_UNTRACKED,
358 'command',
359 'read_only',
360 )
361 out = dict((key, {}) for key in KEYS)
362 for config, values in variables.iteritems():
363 for key in KEYS:
364 if key == 'command':
365 items = [tuple(values[key])] if key in values else []
366 elif key == 'read_only':
367 items = [values[key]] if key in values else []
368 else:
369 assert key in (KEY_TOUCHED, KEY_TRACKED, KEY_UNTRACKED)
370 items = values.get(key, [])
371 for item in items:
372 out[key].setdefault(item, set()).add(config)
373 return out
374
375
376def reduce_inputs(values):
377 """Reduces the output of invert_map() to the strictest minimum list.
378
379 Looks at each individual file and directory, maps where they are used and
380 reconstructs the inverse dictionary.
381
382 Returns the minimized dictionary.
383 """
384 KEYS = (
385 KEY_TOUCHED,
386 KEY_TRACKED,
387 KEY_UNTRACKED,
388 'command',
389 'read_only',
390 )
391
392 # Folders can only live in KEY_UNTRACKED.
393 folders = get_folders(values.get(KEY_UNTRACKED, {}))
394
395 out = dict((key, {}) for key in KEYS)
396 for key in KEYS:
397 for item, item_configs in values.get(key, {}).iteritems():
398 item_configs = remove_weak_dependencies(values, key, item, item_configs)
399 item_configs = remove_repeated_dependencies(
400 folders, key, item, item_configs)
401 if item_configs:
402 out[key][item] = item_configs
403 return out
404
405
406def convert_map_to_isolate_dict(values, config_variables):
407 """Regenerates back a .isolate configuration dict from files and dirs
408 mappings generated from reduce_inputs().
409 """
410 # Gather a list of configurations for set inversion later.
411 all_mentioned_configs = set()
412 for configs_by_item in values.itervalues():
413 for configs in configs_by_item.itervalues():
414 all_mentioned_configs.update(configs)
415
416 # Invert the mapping to make it dict first.
417 conditions = {}
418 for key in values:
419 for item, configs in values[key].iteritems():
420 then = conditions.setdefault(frozenset(configs), {})
421 variables = then.setdefault('variables', {})
422
Marc-Antoine Ruel7124e392014-01-09 11:49:21 -0500423 if isinstance(item, int):
Marc-Antoine Ruela5a36222014-01-09 10:35:45 -0500424 # One-off for read_only.
425 variables[key] = item
426 else:
427 assert item
428 if isinstance(item, tuple):
429 # One-off for command.
430 # Do not merge lists and do not sort!
431 # Note that item is a tuple.
432 assert key not in variables
433 variables[key] = list(item)
434 else:
435 # The list of items (files or dirs). Append the new item and keep
436 # the list sorted.
437 l = variables.setdefault(key, [])
438 l.append(item)
439 l.sort()
440
441 if all_mentioned_configs:
442 config_values = map(set, zip(*all_mentioned_configs))
443 sef = short_expression_finder.ShortExpressionFinder(
444 zip(config_variables, config_values))
445
446 conditions = sorted(
447 [sef.get_expr(configs), then] for configs, then in conditions.iteritems())
448 return {'conditions': conditions}
449
450
451class ConfigSettings(object):
452 """Represents the dependency variables for a single build configuration.
453 The structure is immutable.
454 """
455 def __init__(self, config, values):
456 self.config = config
457 verify_variables(values)
458 self.touched = sorted(values.get(KEY_TOUCHED, []))
459 self.tracked = sorted(values.get(KEY_TRACKED, []))
460 self.untracked = sorted(values.get(KEY_UNTRACKED, []))
461 self.command = values.get('command', [])[:]
462 self.read_only = values.get('read_only')
463
464 def union(self, rhs):
465 """Merges two config settings together.
466
467 self has priority over rhs for 'command' variable.
468 """
469 assert not (self.config and rhs.config) or (self.config == rhs.config)
470 var = {
471 KEY_TOUCHED: sorted(self.touched + rhs.touched),
472 KEY_TRACKED: sorted(self.tracked + rhs.tracked),
473 KEY_UNTRACKED: sorted(self.untracked + rhs.untracked),
474 'command': self.command or rhs.command,
475 'read_only': rhs.read_only if self.read_only is None else self.read_only,
476 }
477 return ConfigSettings(self.config or rhs.config, var)
478
479 def flatten(self):
480 out = {}
481 if self.command:
482 out['command'] = self.command
483 if self.touched:
484 out[KEY_TOUCHED] = self.touched
485 if self.tracked:
486 out[KEY_TRACKED] = self.tracked
487 if self.untracked:
488 out[KEY_UNTRACKED] = self.untracked
489 if self.read_only is not None:
490 out['read_only'] = self.read_only
491 return out
492
493
494class Configs(object):
495 """Represents a processed .isolate file.
496
497 Stores the file in a processed way, split by configuration.
498 """
499 def __init__(self, file_comment):
500 self.file_comment = file_comment
501 # The keys of by_config are tuples of values for the configuration
502 # variables. The names of the variables (which must be the same for
503 # every by_config key) are kept in config_variables. Initially by_config
504 # is empty and we don't know what configuration variables will be used,
505 # so config_variables also starts out empty. It will be set by the first
506 # call to union() or merge_dependencies().
507 self.by_config = {}
508 self.config_variables = ()
509
510 def union(self, rhs):
511 """Adds variables from rhs (a Configs) to the existing variables.
512 """
513 config_variables = self.config_variables
514 if not config_variables:
515 config_variables = rhs.config_variables
516 else:
517 # We can't proceed if this isn't true since we don't know the correct
518 # default values for extra variables. The variables are sorted so we
519 # don't need to worry about permutations.
520 if rhs.config_variables and rhs.config_variables != config_variables:
521 raise isolateserver.ConfigError(
522 'Variables in merged .isolate files do not match: %r and %r' % (
523 config_variables, rhs.config_variables))
524
525 # Takes the first file comment, prefering lhs.
526 out = Configs(self.file_comment or rhs.file_comment)
527 out.config_variables = config_variables
528 for config in set(self.by_config) | set(rhs.by_config):
529 out.by_config[config] = union(
530 self.by_config.get(config), rhs.by_config.get(config))
531 return out
532
533 def merge_dependencies(self, values, config_variables, configs):
534 """Adds new dependencies to this object for the given configurations.
535 Arguments:
536 values: A variables dict as found in a .isolate file, e.g.,
537 {KEY_TOUCHED: [...], 'command': ...}.
538 config_variables: An ordered list of configuration variables, e.g.,
539 ["OS", "chromeos"]. If this object already contains any dependencies,
540 the configuration variables must match.
541 configs: a list of tuples of values of the configuration variables,
542 e.g., [("mac", 0), ("linux", 1)]. The dependencies in |values|
543 are added to all of these configurations, and other configurations
544 are unchanged.
545 """
546 if not values:
547 return
548
549 if not self.config_variables:
550 self.config_variables = config_variables
551 else:
552 # See comment in Configs.union().
553 assert self.config_variables == config_variables
554
555 for config in configs:
556 self.by_config[config] = union(
557 self.by_config.get(config), ConfigSettings(config, values))
558
559 def flatten(self):
560 """Returns a flat dictionary representation of the configuration.
561 """
562 return dict((k, v.flatten()) for k, v in self.by_config.iteritems())
563
564 def make_isolate_file(self):
565 """Returns a dictionary suitable for writing to a .isolate file.
566 """
567 dependencies_by_config = self.flatten()
568 configs_by_dependency = reduce_inputs(invert_map(dependencies_by_config))
569 return convert_map_to_isolate_dict(configs_by_dependency,
570 self.config_variables)
571
572
573# TODO(benrg): Remove this function when no old-format files are left.
574def convert_old_to_new_format(value):
575 """Converts from the old .isolate format, which only has one variable (OS),
576 always includes 'linux', 'mac' and 'win' in the set of valid values for OS,
577 and allows conditions that depend on the set of all OSes, to the new format,
578 which allows any set of variables, has no hardcoded values, and only allows
579 explicit positive tests of variable values.
580 """
581 conditions = value.get('conditions', [])
582 if 'variables' not in value and all(len(cond) == 2 for cond in conditions):
583 return value # Nothing to change
584
585 def parse_condition(cond):
586 m = re.match(r'OS=="(\w+)"\Z', cond[0])
587 if not m:
588 raise isolateserver.ConfigError('Invalid condition: %s' % cond[0])
589 return m.group(1)
590
591 oses = set(map(parse_condition, conditions))
592 default_oses = set(['linux', 'mac', 'win'])
593 oses = sorted(oses | default_oses)
594
595 def if_not_os(not_os, then):
596 expr = ' or '.join('OS=="%s"' % os for os in oses if os != not_os)
597 return [expr, then]
598
599 conditions = [
600 cond[:2] for cond in conditions if cond[1]
601 ] + [
602 if_not_os(parse_condition(cond), cond[2])
603 for cond in conditions if len(cond) == 3
604 ]
605
606 if 'variables' in value:
607 conditions.append(if_not_os(None, {'variables': value.pop('variables')}))
608 conditions.sort()
609
610 value = value.copy()
611 value['conditions'] = conditions
612 return value
613
614
615def load_isolate_as_config(isolate_dir, value, file_comment):
616 """Parses one .isolate file and returns a Configs() instance.
617
618 Arguments:
619 isolate_dir: only used to load relative includes so it doesn't depend on
620 cwd.
621 value: is the loaded dictionary that was defined in the gyp file.
622 file_comment: comments found at the top of the file so it can be preserved.
623
624 The expected format is strict, anything diverting from the format below will
625 throw an assert:
626 {
627 'includes': [
628 'foo.isolate',
629 ],
630 'conditions': [
631 ['OS=="vms" and foo=42', {
632 'variables': {
633 'command': [
634 ...
635 ],
636 'isolate_dependency_tracked': [
637 ...
638 ],
639 'isolate_dependency_untracked': [
640 ...
641 ],
Marc-Antoine Ruel7124e392014-01-09 11:49:21 -0500642 'read_only': 0,
Marc-Antoine Ruela5a36222014-01-09 10:35:45 -0500643 },
644 }],
645 ...
646 ],
647 }
648 """
649 value = convert_old_to_new_format(value)
650
651 variables_and_values = {}
652 verify_root(value, variables_and_values)
653 if variables_and_values:
654 config_variables, config_values = zip(
655 *sorted(variables_and_values.iteritems()))
656 all_configs = list(itertools.product(*config_values))
657 else:
658 config_variables = None
659 all_configs = []
660
661 isolate = Configs(file_comment)
662
663 # Add configuration-specific variables.
664 for expr, then in value.get('conditions', []):
665 configs = match_configs(expr, config_variables, all_configs)
666 isolate.merge_dependencies(then['variables'], config_variables, configs)
667
668 # Load the includes. Process them in reverse so the last one take precedence.
669 for include in reversed(value.get('includes', [])):
670 if os.path.isabs(include):
671 raise isolateserver.ConfigError(
672 'Failed to load configuration; absolute include path \'%s\'' %
673 include)
674 included_isolate = os.path.normpath(os.path.join(isolate_dir, include))
675 with open(included_isolate, 'r') as f:
676 included_isolate = load_isolate_as_config(
677 os.path.dirname(included_isolate),
678 eval_content(f.read()),
679 None)
680 isolate = union(isolate, included_isolate)
681
682 return isolate
683
684
685def load_isolate_for_config(isolate_dir, content, config_variables):
686 """Loads the .isolate file and returns the information unprocessed but
687 filtered for the specific OS.
688
689 Returns the command, dependencies and read_only flag. The dependencies are
690 fixed to use os.path.sep.
691 """
692 # Load the .isolate file, process its conditions, retrieve the command and
693 # dependencies.
694 isolate = load_isolate_as_config(isolate_dir, eval_content(content), None)
695 try:
696 config_name = tuple(
697 config_variables[var] for var in isolate.config_variables)
698 except KeyError:
699 raise isolateserver.ConfigError(
700 'These configuration variables were missing from the command line: %s' %
701 ', '.join(
702 sorted(set(isolate.config_variables) - set(config_variables))))
703 config = isolate.by_config.get(config_name)
704 if not config:
705 raise isolateserver.ConfigError(
706 'Failed to load configuration for variable \'%s\' for config(s) \'%s\''
707 '\nAvailable configs: %s' %
708 (', '.join(isolate.config_variables),
709 ', '.join(config_name),
710 ', '.join(str(s) for s in isolate.by_config)))
711 # Merge tracked and untracked variables, isolate.py doesn't care about the
712 # trackability of the variables, only the build tool does.
713 dependencies = [
714 f.replace('/', os.path.sep) for f in config.tracked + config.untracked
715 ]
716 touched = [f.replace('/', os.path.sep) for f in config.touched]
717 return config.command, dependencies, touched, config.read_only