3
# This Source Code Form is subject to the terms of the Mozilla Public
4
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
5
# You can obtain one at http://mozilla.org/MPL/2.0/.
8
Mozilla universal manifest parser
12
# http://hg.mozilla.org/automation/ManifestDestiny/raw-file/tip/manifestparser.py
14
__all__ = ['read_ini', # .ini reader
15
'ManifestParser', 'TestManifest', 'convert', # manifest handling
16
'parse', 'ParseError', 'ExpressionParser'] # conditional expression parser
22
from fnmatch import fnmatch
23
from optparse import OptionParser
25
version = '0.5.4' # package version
27
from setuptools import setup
31
# we need relpath, but it is introduced in python 2.6
32
# http://docs.python.org/library/os.path.html
34
relpath = os.path.relpath
35
except AttributeError:
36
def relpath(path, start):
38
Return a relative version of a path
39
from /usr/lib/python2.6/posixpath.py
43
raise ValueError("no path specified")
45
start_list = os.path.abspath(start).split(os.path.sep)
46
path_list = os.path.abspath(path).split(os.path.sep)
48
# Work out how much of the filepath is shared by start and path.
49
i = len(os.path.commonprefix([start_list, path_list]))
51
rel_list = [os.path.pardir] * (len(start_list)-i) + path_list[i:]
54
return os.path.join(*rel_list)
58
# http://k0s.org/mozilla/hg/expressionparser
59
# http://hg.mozilla.org/users/tmielczarek_mozilla.com/expressionparser
61
# Implements a top-down parser/evaluator for simple boolean expressions.
62
# ideas taken from http://effbot.org/zone/simple-top-down-parsing.htm
78
# IDENT := [A-Za-z_]\w*
80
# Identifiers take their values from a mapping dictionary passed as the second
83
# Glossary (see above URL for details):
84
# - nud: null denotation
85
# - led: left detonation
86
# - lbp: left binding power
87
# - rbp: right binding power
89
class ident_token(object):
90
def __init__(self, value):
92
def nud(self, parser):
93
# identifiers take their value from the value mappings passed
95
return parser.value(self.value)
97
class literal_token(object):
98
def __init__(self, value):
100
def nud(self, parser):
103
class eq_op_token(object):
105
def led(self, parser, left):
106
return left == parser.expression(self.lbp)
108
class neq_op_token(object):
110
def led(self, parser, left):
111
return left != parser.expression(self.lbp)
113
class not_op_token(object):
115
def nud(self, parser):
116
return not parser.expression()
118
class and_op_token(object):
120
def led(self, parser, left):
121
right = parser.expression(self.lbp)
122
return left and right
124
class or_op_token(object):
126
def led(self, parser, left):
127
right = parser.expression(self.lbp)
130
class lparen_token(object):
132
def nud(self, parser):
133
expr = parser.expression()
134
parser.advance(rparen_token)
137
class rparen_token(object):
140
class end_token(object):
141
"""always ends parsing"""
143
### derived literal tokens
145
class bool_token(literal_token):
146
def __init__(self, value):
147
value = {'true':True, 'false':False}[value]
148
literal_token.__init__(self, value)
150
class int_token(literal_token):
151
def __init__(self, value):
152
literal_token.__init__(self, int(value))
154
class string_token(literal_token):
155
def __init__(self, value):
156
literal_token.__init__(self, value[1:-1])
158
precedence = [(end_token, rparen_token),
161
(eq_op_token, neq_op_token),
164
for index, rank in enumerate(precedence):
166
token.lbp = index # lbp = lowest left binding power
168
class ParseError(Exception):
169
"""errror parsing conditional expression"""
171
class ExpressionParser(object):
172
def __init__(self, text, valuemapping, strict=False):
174
Initialize the parser with input |text|, and |valuemapping| as
175
a dict mapping identifier names to values.
178
self.valuemapping = valuemapping
183
Lex the input text into tokens and yield them in sequence.
186
def bool_(scanner, t): return bool_token(t)
187
def identifier(scanner, t): return ident_token(t)
188
def integer(scanner, t): return int_token(t)
189
def eq(scanner, t): return eq_op_token()
190
def neq(scanner, t): return neq_op_token()
191
def or_(scanner, t): return or_op_token()
192
def and_(scanner, t): return and_op_token()
193
def lparen(scanner, t): return lparen_token()
194
def rparen(scanner, t): return rparen_token()
195
def string_(scanner, t): return string_token(t)
196
def not_(scanner, t): return not_op_token()
198
scanner = re.Scanner([
199
(r"true|false", bool_),
200
(r"[a-zA-Z_]\w*", identifier),
201
(r"[0-9]+", integer),
202
(r'("[^"]*")|(\'[^\']*\')', string_),
210
(r"\s+", None), # skip whitespace
212
tokens, remainder = scanner.scan(self.text)
217
def value(self, ident):
219
Look up the value of |ident| in the value mapping passed in the
223
return self.valuemapping[ident]
225
return self.valuemapping.get(ident, None)
227
def advance(self, expected):
229
Assert that the next token is an instance of |expected|, and advance
232
if not isinstance(self.token, expected):
233
raise Exception, "Unexpected token!"
234
self.token = self.iter.next()
236
def expression(self, rbp=0):
238
Parse and return the value of an expression until a token with
239
right binding power greater than rbp is encountered.
242
self.token = self.iter.next()
244
while rbp < self.token.lbp:
246
self.token = self.iter.next()
247
left = t.led(self, left)
252
Parse and return the value of the expression in the text
253
passed to the constructor. Raises a ParseError if the expression
257
self.iter = self._tokenize()
258
self.token = self.iter.next()
259
return self.expression()
261
raise ParseError("could not parse: %s; variables: %s" % (self.text, self.valuemapping))
265
def parse(text, **values):
267
Parse and evaluate a boolean expression in |text|. Use |values| to look
268
up the value of identifiers referenced in the expression. Returns the final
269
value of the expression. A ParseError will be raised if parsing fails.
271
return ExpressionParser(text, values).parse()
273
def normalize_path(path):
274
"""normalize a relative path"""
275
if sys.platform.startswith('win'):
276
return path.replace('/', os.path.sep)
279
def denormalize_path(path):
280
"""denormalize a relative path"""
281
if sys.platform.startswith('win'):
282
return path.replace(os.path.sep, '/')
286
def read_ini(fp, variables=None, default='DEFAULT',
287
comments=';#', separators=('=', ':'),
290
read an .ini file and return a list of [(section, values)]
291
- fp : file pointer or path to read
292
- variables : default set of variables
293
- default : name of the section for the default section
294
- comments : characters that if they start a line denote a comment
295
- separators : strings that denote key, value separation in order
296
- strict : whether to be strict about parsing
299
if variables is None:
302
if isinstance(fp, basestring):
307
section_names = set([])
310
for line in fp.readlines():
312
stripped = line.strip()
316
# reset key and value to avoid continuation lines
320
# ignore comment lines
321
if stripped[0] in comments:
324
# check for a new section
325
if len(stripped) > 2 and stripped[0] == '[' and stripped[-1] == ']':
326
section = stripped[1:-1].strip()
329
# deal with DEFAULT section
330
if section.lower() == default.lower():
332
assert default not in section_names
333
section_names.add(default)
334
current_section = variables
338
# make sure this section doesn't already exist
339
assert section not in section_names, "Section '%s' already found in '%s'" % (section, section_names)
341
section_names.add(section)
343
sections.append((section, current_section))
346
# if there aren't any sections yet, something bad happen
347
if not section_names:
348
raise Exception('No sections found')
351
for separator in separators:
352
if separator in stripped:
353
key, value = stripped.split(separator, 1)
355
value = value.strip()
358
# make sure this key isn't already in the section or empty
360
if current_section is not variables:
361
assert key not in current_section
363
current_section[key] = value
366
# continuation line ?
367
if line[0].isspace() and key:
368
value = '%s%s%s' % (value, os.linesep, stripped)
369
current_section[key] = value
371
# something bad happen!
372
raise Exception("Not sure what you're trying to do")
374
# interpret the variables
375
def interpret_variables(global_dict, local_dict):
376
variables = global_dict.copy()
377
variables.update(local_dict)
380
sections = [(i, interpret_variables(variables, j)) for i, j in sections]
384
### objects for parsing manifests
386
class ManifestParser(object):
387
"""read .ini manifests"""
389
### methods for reading manifests
391
def __init__(self, manifests=(), defaults=None, strict=True):
392
self._defaults = defaults or {}
396
self.relativeRoot = None
398
self.read(*manifests)
400
def getRelativeRoot(self, root):
403
def read(self, *filenames, **defaults):
405
# ensure all files exist
406
missing = [ filename for filename in filenames
407
if not os.path.exists(filename) ]
409
raise IOError('Missing files: %s' % ', '.join(missing))
412
for filename in filenames:
414
# set the per file defaults
415
defaults = defaults.copy() or self._defaults.copy()
416
here = os.path.dirname(os.path.abspath(filename))
417
defaults['here'] = here
419
if self.rootdir is None:
420
# set the root directory
421
# == the directory of the first manifest given
424
# read the configuration
425
sections = read_ini(fp=filename, variables=defaults, strict=self.strict)
428
for section, data in sections:
431
# TODO: keep track of included file structure:
432
# self.manifests = {'manifest.ini': 'relative/path.ini'}
433
if section.startswith('include:'):
434
include_file = section.split('include:', 1)[-1]
435
include_file = normalize_path(include_file)
436
if not os.path.isabs(include_file):
437
include_file = os.path.join(self.getRelativeRoot(here), include_file)
438
if not os.path.exists(include_file):
440
raise IOError("File '%s' does not exist" % include_file)
443
include_defaults = data.copy()
444
self.read(include_file, **include_defaults)
449
test['name'] = section
450
test['manifest'] = os.path.abspath(filename)
453
path = test.get('path', section)
454
if '://' not in path: # don't futz with URLs
455
path = normalize_path(path)
456
if not os.path.isabs(path):
457
path = os.path.join(here, path)
461
self.tests.append(test)
463
### methods for querying manifests
465
def query(self, *checks, **kw):
467
general query function for tests
468
- checks : callable conditions to test if the test fulfills the query
470
tests = kw.get('tests', None)
482
def get(self, _key=None, inverse=False, tags=None, tests=None, **kwargs):
483
# TODO: pass a dict instead of kwargs since you might hav
484
# e.g. 'inverse' as a key in the dict
486
# TODO: tags should just be part of kwargs with None values
487
# (None == any is kinda weird, but probably still better)
495
# make some check functions
497
has_tags = lambda test: not tags.intersection(test.keys())
498
def dict_query(test):
499
for key, value in kwargs.items():
500
if test.get(key) == value:
504
has_tags = lambda test: tags.issubset(test.keys())
505
def dict_query(test):
506
for key, value in kwargs.items():
507
if test.get(key) != value:
512
tests = self.query(has_tags, dict_query, tests=tests)
514
# if a key is given, return only a list of that key
515
# useful for keys like 'name' or 'path'
517
return [test[_key] for test in tests]
522
def missing(self, tests=None):
523
"""return list of tests that do not exist on the filesystem"""
526
return [test for test in tests
527
if not os.path.exists(test['path'])]
529
def manifests(self, tests=None):
531
return manifests in order in which they appear in the tests
537
manifest = test.get('manifest')
540
if manifest not in manifests:
541
manifests.append(manifest)
544
### methods for outputting from manifests
546
def write(self, fp=sys.stdout, rootdir=None,
547
global_tags=None, global_kwargs=None,
548
local_tags=None, local_kwargs=None):
550
write a manifest given a query
551
global and local options will be munged to do the query
552
globals will be written to the top of the file
553
locals (if given) will be written per test
558
rootdir = self.rootdir
561
global_tags = global_tags or set()
562
local_tags = local_tags or set()
563
global_kwargs = global_kwargs or {}
564
local_kwargs = local_kwargs or {}
568
tags.update(global_tags)
569
tags.update(local_tags)
571
kwargs.update(global_kwargs)
572
kwargs.update(local_kwargs)
575
tests = self.get(tags=tags, **kwargs)
577
# print the .ini manifest
578
if global_tags or global_kwargs:
579
print >> fp, '[DEFAULT]'
580
for tag in global_tags:
581
print >> fp, '%s =' % tag
582
for key, value in global_kwargs.items():
583
print >> fp, '%s = %s' % (key, value)
587
test = test.copy() # don't overwrite
590
if not os.path.isabs(path):
593
path = relpath(test['path'], self.rootdir)
594
path = denormalize_path(path)
595
print >> fp, '[%s]' % path
598
reserved = ['path', 'name', 'here', 'manifest']
599
for key in sorted(test.keys()):
602
if key in global_kwargs:
604
if key in global_tags and not test[key]:
606
print >> fp, '%s = %s' % (key, test[key])
609
def copy(self, directory, rootdir=None, *tags, **kwargs):
611
copy the manifests and associated tests
612
- directory : directory to copy to
613
- rootdir : root directory to copy to (if not given from manifests)
614
- tags : keywords the tests must have
615
- kwargs : key, values the tests must match
617
# XXX note that copy does *not* filter the tests out of the
618
# resulting manifest; it just stupidly copies them over.
619
# ideally, it would reread the manifests and filter out the
620
# tests that don't match *tags and **kwargs
623
if not os.path.exists(directory):
624
os.path.makedirs(directory)
627
assert os.path.isdir(directory)
630
tests = self.get(tags=tags, **kwargs)
632
return # nothing to do!
636
rootdir = self.rootdir
638
# copy the manifests + tests
639
manifests = [relpath(manifest, rootdir) for manifest in self.manifests()]
640
for manifest in manifests:
641
destination = os.path.join(directory, manifest)
642
dirname = os.path.dirname(destination)
643
if not os.path.exists(dirname):
647
assert os.path.isdir(dirname)
648
shutil.copy(os.path.join(rootdir, manifest), destination)
650
if os.path.isabs(test['name']):
652
source = test['path']
653
if not os.path.exists(source):
654
print >> sys.stderr, "Missing test: '%s' does not exist!" % source
656
# TODO: should err on strict
657
destination = os.path.join(directory, relpath(test['path'], rootdir))
658
shutil.copy(source, destination)
659
# TODO: ensure that all of the tests are below the from_dir
661
def update(self, from_dir, rootdir=None, *tags, **kwargs):
663
update the tests as listed in a manifest from a directory
664
- from_dir : directory where the tests live
665
- rootdir : root directory to copy to (if not given from manifests)
666
- tags : keys the tests must have
667
- kwargs : key, values the tests must match
671
tests = self.get(tags=tags, **kwargs)
673
# get the root directory
675
rootdir = self.rootdir
679
if not os.path.isabs(test['name']):
680
_relpath = relpath(test['path'], rootdir)
681
source = os.path.join(from_dir, _relpath)
682
if not os.path.exists(source):
684
print >> sys.stderr, "Missing test: '%s'; skipping" % test['name']
686
destination = os.path.join(rootdir, _relpath)
687
shutil.copy(source, destination)
690
class TestManifest(ManifestParser):
692
apply logic to manifests; this is your integration layer :)
693
specific harnesses may subclass from this if they need more logic
696
def filter(self, values, tests):
698
filter on a specific list tag, e.g.:
699
run-if = os == win linux
710
reason = None # reason to disable
712
# tagged-values to run
714
condition = test[run_tag]
715
if not parse(condition, **values):
716
reason = '%s: %s' % (run_tag, condition)
718
# tagged-values to skip
720
condition = test[skip_tag]
721
if parse(condition, **values):
722
reason = '%s: %s' % (skip_tag, condition)
724
# mark test as disabled if there's a reason
726
test.setdefault('disabled', reason)
728
# mark test as a fail if so indicated
730
condition = test[fail_tag]
731
if parse(condition, **values):
732
test['expected'] = 'fail'
734
def active_tests(self, exists=True, disabled=True, **values):
736
- exists : return only existing tests
737
- disabled : whether to return disabled tests
738
- tags : keys and values to filter on (e.g. `os = linux mac`)
741
tests = [i.copy() for i in self.tests] # shallow copy
743
# mark all tests as passing unless indicated otherwise
745
test['expected'] = test.get('expected', 'pass')
747
# ignore tests that do not exist
749
tests = [test for test in tests if os.path.exists(test['path'])]
752
self.filter(values, tests)
754
# ignore disabled tests if specified
756
tests = [test for test in tests
757
if not 'disabled' in test]
759
# return active tests
762
def test_paths(self):
763
return [test['path'] for test in self.active_tests()]
766
### utility function(s); probably belongs elsewhere
768
def convert(directories, pattern=None, ignore=(), write=None):
770
convert directories to a simple manifest
775
for directory in directories:
776
for dirpath, dirnames, filenames in os.walk(directory):
778
# filter out directory names
779
dirnames = [ i for i in dirnames if i not in ignore ]
782
# reference only the subdirectory
784
dirpath = dirpath.split(directory, 1)[-1].strip(os.path.sep)
786
if dirpath.split(os.path.sep)[0] in ignore:
791
filenames = [filename for filename in filenames
792
if fnmatch(filename, pattern)]
796
# write a manifest for each directory
797
if write and (dirnames or filenames):
798
manifest = file(os.path.join(_dirpath, write), 'w')
799
for dirname in dirnames:
800
print >> manifest, '[include:%s]' % os.path.join(dirname, write)
801
for filename in filenames:
802
print >> manifest, '[%s]' % filename
806
retval.extend([denormalize_path(os.path.join(dirpath, filename))
807
for filename in filenames])
810
return # the manifests have already been written!
813
retval = ['[%s]' % filename for filename in retval]
814
return '\n'.join(retval)
816
### command line attributes
818
class ParserError(Exception):
819
"""error for exceptions while parsing the command line"""
821
def parse_args(_args):
824
--keys=value (or --key value)
834
# parse the arguments
837
if arg.startswith('---'):
838
raise ParserError("arguments should start with '-' or '--' only")
839
elif arg.startswith('--'):
841
raise ParserError("Key %s still open" % key)
844
key, value = key.split('=', 1)
848
elif arg.startswith('-'):
850
raise ParserError("Key %s still open" % key)
860
return (_dict, tags, args)
863
### classes for subcommands
865
class CLICommand(object):
866
usage = '%prog [options] command'
867
def __init__(self, parser):
868
self._parser = parser # master parser
870
return OptionParser(usage=self.usage, description=self.__doc__,
871
add_help_option=False)
873
class Copy(CLICommand):
874
usage = '%prog [options] copy manifest directory -tag1 -tag2 --key1=value1 --key2=value2 ...'
875
def __call__(self, options, args):
876
# parse the arguments
878
kwargs, tags, args = parse_args(args)
879
except ParserError, e:
880
self._parser.error(e.message)
882
# make sure we have some manifests, otherwise it will
884
if not len(args) == 2:
885
HelpCLI(self._parser)(options, ['copy'])
889
# TODO: should probably ensure these exist here
890
manifests = ManifestParser()
891
manifests.read(args[0])
893
# print the resultant query
894
manifests.copy(args[1], None, *tags, **kwargs)
897
class CreateCLI(CLICommand):
899
create a manifest from a list of directories
901
usage = '%prog [options] create directory <directory> <...>'
904
parser = CLICommand.parser(self)
905
parser.add_option('-p', '--pattern', dest='pattern',
906
help="glob pattern for files")
907
parser.add_option('-i', '--ignore', dest='ignore',
908
default=[], action='append',
909
help='directories to ignore')
910
parser.add_option('-w', '--in-place', dest='in_place',
911
help='Write .ini files in place; filename to write to')
914
def __call__(self, _options, args):
915
parser = self.parser()
916
options, args = parser.parse_args(args)
918
# need some directories
923
# add the directories to the manifest
925
assert os.path.exists(arg)
926
assert os.path.isdir(arg)
927
manifest = convert(args, pattern=options.pattern, ignore=options.ignore,
928
write=options.in_place)
933
class WriteCLI(CLICommand):
935
write a manifest based on a query
937
usage = '%prog [options] write manifest <manifest> -tag1 -tag2 --key1=value1 --key2=value2 ...'
938
def __call__(self, options, args):
940
# parse the arguments
942
kwargs, tags, args = parse_args(args)
943
except ParserError, e:
944
self._parser.error(e.message)
946
# make sure we have some manifests, otherwise it will
949
HelpCLI(self._parser)(options, ['write'])
953
# TODO: should probably ensure these exist here
954
manifests = ManifestParser()
955
manifests.read(*args)
957
# print the resultant query
958
manifests.write(global_tags=tags, global_kwargs=kwargs)
961
class HelpCLI(CLICommand):
963
get help on a command
965
usage = '%prog [options] help [command]'
967
def __call__(self, options, args):
968
if len(args) == 1 and args[0] in commands:
969
commands[args[0]](self._parser).parser().print_help()
971
self._parser.print_help()
973
for command in sorted(commands):
974
print ' %s : %s' % (command, commands[command].__doc__.strip())
976
class UpdateCLI(CLICommand):
978
update the tests as listed in a manifest from a directory
980
usage = '%prog [options] update manifest directory -tag1 -tag2 --key1=value1 --key2=value2 ...'
982
def __call__(self, options, args):
983
# parse the arguments
985
kwargs, tags, args = parse_args(args)
986
except ParserError, e:
987
self._parser.error(e.message)
989
# make sure we have some manifests, otherwise it will
991
if not len(args) == 2:
992
HelpCLI(self._parser)(options, ['update'])
996
# TODO: should probably ensure these exist here
997
manifests = ManifestParser()
998
manifests.read(args[0])
1000
# print the resultant query
1001
manifests.update(args[1], None, *tags, **kwargs)
1004
# command -> class mapping
1005
commands = { 'create': CreateCLI,
1007
'update': UpdateCLI,
1010
def main(args=sys.argv[1:]):
1011
"""console_script entry point"""
1013
# set up an option parser
1014
usage = '%prog [options] [command] ...'
1015
description = __doc__
1016
parser = OptionParser(usage=usage, description=description)
1017
parser.add_option('-s', '--strict', dest='strict',
1018
action='store_true', default=False,
1019
help='adhere strictly to errors')
1020
parser.disable_interspersed_args()
1022
options, args = parser.parse_args(args)
1025
HelpCLI(parser)(options, args)
1030
if command not in commands:
1031
parser.error("Command must be one of %s (you gave '%s')" % (', '.join(sorted(commands.keys())), command))
1033
handler = commands[command](parser)
1034
handler(options, args[1:])
1036
if __name__ == '__main__':