glib/gio/gsettings-schema-convert
Vincent Untz fb19c20ca1 Make the simple schema parser a class
This makes it easier to track the current state, and, for example, put
the line number in error messages.
2010-04-17 11:13:12 -04:00

876 lines
30 KiB
Python
Executable File

#!/usr/bin/env python
# vim: set ts=4 sw=4 et: coding=UTF-8
#
# Copyright (c) 2010, Novell, Inc.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307,
# USA.
#
# Authors: Vincent Untz <vuntz@gnome.org>
# TODO: support alias for choice
import os
import sys
import optparse
try:
from lxml import etree as ET
except ImportError:
try:
from xml.etree import cElementTree as ET
except ImportError:
import cElementTree as ET
GSETTINGS_SIMPLE_SCHEMA_INDENT = ' '
TYPES_FOR_CHOICES = [ 's' ]
TYPES_FOR_RANGE = [ 'y', 'n', 'q', 'i', 'u', 'x', 't', 'h', 'd' ]
######################################
class GSettingsSchemaConvertException(Exception):
pass
######################################
class GSettingsSchemaRoot:
def __init__(self):
self.schemas = []
self.gettext_domain = None
def simplify(self):
for schema in self.schemas:
schema.simplify()
def get_simple_string(self):
need_empty_line = False
result = ''
for schema in self.schemas:
if need_empty_line:
result += '\n'
result += schema.get_simple_string()
if result:
need_empty_line = True
# Only put the gettext domain if we have some content
if result and self.gettext_domain:
result = 'gettext-domain: %s\n\n%s' % (self.gettext_domain, result)
return result
def get_xml_node(self):
schemalist_node = ET.Element('schemalist')
if self.gettext_domain:
schemalist_node.set('gettext-domain', self.gettext_domain)
for schema in self.schemas:
for schema_node in schema.get_xml_nodes():
schemalist_node.append(schema_node)
return schemalist_node
######################################
# Note: defined before GSettingsSchema because GSettingsSchema is a subclass.
# But from a schema point of view, GSettingsSchema is a parent of
# GSettingsSchemaDir.
class GSettingsSchemaDir:
def __init__(self):
self.name = None
self.gettext_domain = None
self.dirs = []
self.keys = []
def get_simple_string(self, current_indent):
content = self._get_simple_string_for_content(current_indent)
if not content:
return ''
result = ''
result += '%schild %s:\n' % (current_indent, self.name)
if self.gettext_domain:
result += '%sgettext-domain: %s\n' % (current_indent + GSETTINGS_SIMPLE_SCHEMA_INDENT, self.gettext_domain)
result += '\n'
result += content
return result
def _get_simple_string_for_content(self, current_indent):
need_empty_line = False
result = ''
for key in self.keys:
result += key.get_simple_string(current_indent + GSETTINGS_SIMPLE_SCHEMA_INDENT)
need_empty_line = True
for dir in self.dirs:
if need_empty_line:
result += '\n'
result += dir.get_simple_string(current_indent + GSETTINGS_SIMPLE_SCHEMA_INDENT)
if result:
need_empty_line = True
return result
def get_xml_nodes(self, parent_id, parent_path):
id = '%s.%s' % (parent_id, self.name)
path = '%s%s/' % (parent_path, self.name)
(node, children) = self._get_xml_nodes_for_content(id, path)
if node is None:
return []
node.set('id', id)
node.set('path', path)
nodes = [ node ]
nodes.extend(children)
return nodes
def _get_xml_nodes_for_content(self, id, path):
if not self.keys and not self.dirs:
return (None, None)
children = []
schema_node = ET.Element('schema')
if self.gettext_domain:
schema_node.set('gettext-domain', self.gettext_domain)
for key in self.keys:
key_node = key.get_xml_node()
schema_node.append(key_node)
for dir in self.dirs:
dir_nodes = dir.get_xml_nodes(id, path)
children.extend(dir_nodes)
child_node = ET.SubElement(schema_node, 'child')
child_node.set('name', dir.name)
child_node.set('schema', '%s.%s' % (id, dir.name))
return (schema_node, children)
######################################
class GSettingsSchema(GSettingsSchemaDir):
def __init__(self):
self.id = None
self.path = None
self.gettext_domain = None
self.dirs = []
self.keys = []
def simplify(self):
while len(self.dirs) == 1 and not self.keys:
dir = self.dirs[0]
self.dirs = dir.dirs
self.keys = dir.keys
if self.path:
self.path += dir.name + '/'
def get_simple_string(self):
if not self.dirs and not self.keys:
return ''
result = ''
result += 'schema %s:\n' % self.id
if self.gettext_domain:
result += '%sgettext-domain: %s\n' % (GSETTINGS_SIMPLE_SCHEMA_INDENT, self.gettext_domain)
if self.path:
result += '%spath: %s\n' % (GSETTINGS_SIMPLE_SCHEMA_INDENT, self.path)
result += '\n'
result += self._get_simple_string_for_content('')
return result
def get_xml_nodes(self):
if not self.dirs and not self.keys:
return []
(node, children) = self._get_xml_nodes_for_content(self.id, self.path or '')
if node is None:
return []
node.set('id', self.id)
if self.path:
node.set('path', self.path)
nodes = [ node ]
nodes.extend(children)
return nodes
######################################
class GSettingsSchemaKey:
def __init__(self):
self.name = None
self.type = None
self.default = None
self.typed_default = None
self.l10n = None
self.l10n_context = None
self.summary = None
self.description = None
self.choices = None
self.range = None
def fill(self, name, type, default, typed_default, l10n, l10n_context, summary, description, choices, range):
self.name = name
self.type = type
self.default = default
self.typed_default = typed_default
self.l10n = l10n
self.l10n_context = l10n_context
self.summary = summary
self.description = description
self.choices = choices
self.range = range
def _has_range_choices(self):
return self.choices is not None and self.type in TYPES_FOR_CHOICES
def _has_range_minmax(self):
return self.range is not None and len(self.range) == 2 and self.type in TYPES_FOR_RANGE
def get_simple_string(self, current_indent):
# FIXME: kill this when we'll have python bindings for GVariant. Right
# now, every simple format schema we'll generate has to have an
# explicit type since we can't guess the type later on when converting
# to XML.
self.typed_default = '@%s %s' % (self.type, self.default)
result = ''
result += '%skey %s = %s\n' % (current_indent, self.name, self.typed_default or self.default)
current_indent += GSETTINGS_SIMPLE_SCHEMA_INDENT
if self.l10n:
l10n = self.l10n
if self.l10n_context:
l10n += ' %s' % self.l10n_context
result += '%sl10n: %s\n' % (current_indent, l10n)
if self.summary:
result += '%ssummary: %s\n' % (current_indent, self.summary)
if self.description:
result += '%sdescription: %s\n' % (current_indent, self.description)
if self._has_range_choices():
result += '%schoices: %s\n' % (current_indent, ', '.join(self.choices))
elif self._has_range_minmax():
result += '%srange: %s\n' % (current_indent, '%s..%s' % self.range)
return result
def get_xml_node(self):
key_node = ET.Element('key')
key_node.set('name', self.name)
key_node.set('type', self.type)
default_node = ET.SubElement(key_node, 'default')
default_node.text = self.default
if self.l10n:
default_node.set('l10n', self.l10n)
if self.l10n_context:
default_node.set('context', self.l10n_context)
if self.summary:
summary_node = ET.SubElement(key_node, 'summary')
summary_node.text = self.summary
if self.description:
description_node = ET.SubElement(key_node, 'description')
description_node.text = self.description
if self._has_range_choices():
choices_node = ET.SubElement(key_node, 'choices')
for choice in self.choices:
choice_node = ET.SubElement(choices_node, 'choice')
choice_node.set('value', choice)
elif self._has_range_minmax():
(min, max) = self.range
range_node = ET.SubElement(key_node, 'range')
min_node = ET.SubElement(range_node, 'min')
if min:
min_node.text = min
max_node = ET.SubElement(range_node, 'max')
if max:
max_node.text = max
return key_node
######################################
def map_gconf_type_to_variant_type(gconftype, gconfsubtype):
typemap = { 'string': 's', 'int': 'i', 'float': 'f', 'bool': 'b', 'list': 'a' }
result = typemap[gconftype]
if gconftype == 'list':
result = result + typemap[gconfsubtype]
return result
class GConfSchema:
def __init__(self, node):
locale_node = node.find('locale')
self.key = node.find('key').text
self.type = node.find('type').text
if self.type == 'list':
self.list_type = node.find('list_type').text
else:
self.list_type = None
self.varianttype = map_gconf_type_to_variant_type(self.type, self.list_type)
applyto_node = node.find('applyto')
if applyto_node is not None:
self.applyto = node.find('applyto').text
self.applyto.strip()
self.keyname = self.applyto[self.applyto.rfind('/')+1:]
self.prefix = self.applyto[:self.applyto.rfind('/')+1]
else:
self.applyto = None
self.key.strip()
self.keyname = self.key[self.key.rfind('/')+1:]
self.prefix = self.key[:self.key.rfind('/')+1]
self.prefix = os.path.normpath(self.prefix)
try:
self.default = locale_node.find('default').text
self.localized = 'messages'
except:
try:
self.default = node.find('default').text
self.localized = None
except:
raise GSettingsSchemaConvertException('No default value for key \'%s\'. A default value is always required in GSettings schemas.' % self.applyto or self.key)
self.typed_default = None
self.short = self._get_value_with_locale(node, locale_node, 'short')
self.long = self._get_value_with_locale(node, locale_node, 'long')
self.short = self._oneline(self.short)
self.long = self._oneline(self.long)
# Fix the default to be parsable by GVariant
if self.type == 'string':
if not self.default:
self.default = '\'\''
else:
self.default.replace('\'', '\\\'')
self.default = '\'%s\'' % self.default
elif self.type == 'bool':
self.default = self.default.lower()
elif self.type == 'list':
l = self.default.strip()
if not (l[0] == '[' and l[-1] == ']'):
raise GSettingsSchemaConvertException('Cannot parse default list value \'%s\' for key \'%s\'.' % (self.default, self.applyto or self.key))
values = l[1:-1].strip()
if not values:
self.typed_default = '@%s []' % self.varianttype
elif self.list_type == 'string':
items = [ item.strip() for item in values.split(',') ]
items = [ item.replace('\'', '\\\'') for item in items ]
values = ', '.join([ '\'%s\'' % item for item in items ])
self.default = '[ %s ]' % values
def _get_value_with_locale(self, node, locale_node, element):
element_node = None
if locale_node is not None:
element_node = locale_node.find(element)
if element_node is None:
element_node = node.find(element)
if element_node is not None:
return element_node.text
else:
return None
def _oneline(self, s):
lines = s.splitlines()
result = ''
for line in lines:
result += ' ' + line.lstrip()
return result.strip()
def get_gsettings_schema_key(self):
key = GSettingsSchemaKey()
key.fill(self.keyname, self.varianttype, self.default, self.typed_default, self.localized, self.keyname, self.short, self.long, None, None)
return key
######################################
class SimpleSchemaParser:
allowed_tokens = {
'' : [ 'gettext-domain', 'schema' ],
'gettext-domain' : [ ],
'schema' : [ 'gettext-domain', 'path', 'child', 'key' ],
'path' : [ ],
'child' : [ 'gettext-domain', 'child', 'key' ],
'key' : [ 'l10n', 'summary', 'description', 'choices', 'range' ],
'l10n' : [ ],
'summary' : [ ],
'description' : [ ],
'choices' : [ ],
'range' : [ ]
}
allowed_separators = [ ':', '=' ]
def __init__(self, file):
self.file = file
self.root = GSettingsSchemaRoot()
# this is just a convenient helper to remove the leading indentation
# that should be common to all lines
self.leading_indent = None
self.indent_stack = []
self.token_stack = []
self.object_stack = [ self.root ]
self.previous_token = None
self.current_token = None
self.unparsed_line = ''
def _eat_indent(self):
line = self.unparsed_line
i = 0
buf = ''
previous_max_index = len(self.indent_stack) - 1
index = -1
while i < len(line) - 1 and line[i].isspace():
buf += line[i]
i += 1
if previous_max_index > index:
if buf == self.indent_stack[index + 1]:
buf = ''
index += 1
continue
elif self.indent_stack[index + 1].startswith(buf):
continue
else:
raise GSettingsSchemaConvertException('Inconsistent indentation.')
else:
continue
if buf and previous_max_index > index:
raise GSettingsSchemaConvertException('Inconsistent indentation.')
elif buf and previous_max_index <= index:
self.indent_stack.append(buf)
elif previous_max_index > index:
self.indent_stack = self.indent_stack[:index + 1]
self.unparsed_line = line[i:]
def _parse_word(self):
line = self.unparsed_line
i = 0
while i < len(line) and not line[i].isspace() and not line[i] in self.allowed_separators:
i += 1
self.unparsed_line = line[i:]
return line[:i]
def _word_to_token(self, word):
lower = word.lower()
if lower and lower in self.allowed_tokens.keys():
return lower
raise GSettingsSchemaConvertException('\'%s\' is not a valid token.' % lower)
def _token_allow_separator(self):
return self.current_token in [ 'gettext-domain', 'path', 'l10n', 'summary', 'description', 'choices', 'range' ]
def _parse_name_without_separator(self):
line = self.unparsed_line
if line[-1] in self.allowed_separators:
line = line[:-1].strip()
self.unparsed_line = ''
# FIXME: we could check there's no space
return line
def _parse_key(self):
line = self.unparsed_line
split = False
for separator in self.allowed_separators:
items = line.split(separator)
if len(items) == 2:
split = True
break
if not split:
raise GSettingsSchemaConvertException('Key \'%s\' cannot be parsed.' % line)
# FIXME: we could check there's no space
name = items[0].strip()
type = ''
value = items[1].strip()
if value[0] == '@':
i = 1
while not value[i].isspace():
i += 1
type = value[1:i]
value = value[i:].strip()
if not value:
raise GSettingsSchemaConvertException('No value specified for key \'%s\' (\'%s\').' % (name, line))
self.unparsed_line = ''
object = GSettingsSchemaKey()
object.name = name
object.type = type
object.default = value
return object
def _parse_l10n(self):
line = self.unparsed_line
items = [ item.strip() for item in line.split(' ') if item.strip() ]
if not items:
self.unparsed_line = ''
return (None, None)
if len(items) == 1:
self.unparsed_line = ''
return (items[0], None)
if len(items) == 2:
self.unparsed_line = ''
return (items[0], items[1])
raise GSettingsSchemaConvertException('Localization \'%s\' cannot be parsed.' % line)
def _parse_choices(self, object):
if object.type in TYPES_FOR_CHOICES:
line = self.unparsed_line
result = [ item.strip() for item in line.split(',') ]
self.unparsed_line = ''
return result
else:
raise GSettingsSchemaConvertException('Key \'%s\' of type \'%s\' cannot have choices.' % (object.name, object.type))
def _parse_range(self, object):
minmax = None
if object.type in TYPES_FOR_RANGE:
line = self.unparsed_line
minmax = [ item.strip() for item in line.split('..') ]
if len(minmax) != 2:
raise GSettingsSchemaConvertException('Range \'%s\' cannot be parsed.' % line)
# FIXME: we'll be able to check min < max once we can convert the
# values with GVariant
self.unparsed_line = ''
return tuple(minmax)
else:
raise GSettingsSchemaConvertException('Key \'%s\' of type \'%s\' cannot have a range.' % (object.name, object.type))
def parse_line(self, line):
# make sure that lines with only spaces are ignored and considered as
# empty lines
self.unparsed_line = line.rstrip()
# ignore empty line
if not self.unparsed_line:
return
# look at the indentation to know where we should be
self._eat_indent()
if self.leading_indent is None:
self.leading_indent = len(self.indent_stack)
# ignore comments
if self.unparsed_line[0] == '#':
return
word = self._parse_word()
if self.current_token:
self.previous_token = self.current_token
self.current_token = self._word_to_token(word)
self.unparsed_line = self.unparsed_line.lstrip()
allow_separator = self._token_allow_separator()
if len(self.unparsed_line) > 0 and self.unparsed_line[0] in self.allowed_separators:
if allow_separator:
self.unparsed_line = self.unparsed_line[1:].lstrip()
else:
raise GSettingsSchemaConvertException('Separator \'%s\' is not allowed after \'%s\'.' % (self.unparsed_line[0], self.current_token))
new_level = len(self.indent_stack) - self.leading_indent
old_level = len(self.token_stack)
if new_level > old_level + 1:
raise GSettingsSchemaConvertException('Internal error: stacks not in sync.')
elif new_level <= old_level:
self.token_stack = self.token_stack[:new_level]
# we always have the root
self.object_stack = self.object_stack[:new_level + 1]
if new_level == 0:
parent_token = ''
else:
parent_token = self.token_stack[-1]
# there's new indentation, but no token is allowed under the previous
# one
if new_level == old_level + 1 and self.previous_token != parent_token:
raise GSettingsSchemaConvertException('\'%s\' is not allowed under \'%s\'.' % (self.current_token, self.previous_token))
if not self.current_token in self.allowed_tokens[parent_token]:
if parent_token:
error = '\'%s\' is not allowed under \'%s\'.' % (self.current_token, parent_token)
else:
error = '\'%s\' is not allowed at the root level.' % self.current_token
raise GSettingsSchemaConvertException(error)
current_object = self.object_stack[-1]
new_object = None
if self.current_token == 'gettext-domain':
current_object.gettext_domain = self.unparsed_line
elif self.current_token == 'schema':
name = self._parse_name_without_separator()
new_object = GSettingsSchema()
new_object.id = name
current_object.schemas.append(new_object)
elif self.current_token == 'path':
current_object.path = self.unparsed_line
elif self.current_token == 'child':
name = self._parse_name_without_separator()
new_object = GSettingsSchemaDir()
new_object.name = name
current_object.dirs.append(new_object)
elif self.current_token == 'key':
new_object = self._parse_key()
current_object.keys.append(new_object)
elif self.current_token == 'l10n':
(current_object.l10n, current_object.l10n_context) = self._parse_l10n()
elif self.current_token == 'summary':
current_object.summary = self.unparsed_line
elif self.current_token == 'description':
current_object.description = self.unparsed_line
elif self.current_token == 'choices':
current_object.choices = self._parse_choices(current_object)
elif self.current_token == 'range':
current_object.range = self._parse_range(current_object)
if new_object:
self.token_stack.append(self.current_token)
self.object_stack.append(new_object)
def parse(self):
f = open(self.file, 'r')
lines = [ line[:-1] for line in f.readlines() ]
f.close()
try:
current_line_nb = 0
for line in lines:
current_line_nb += 1
self.parse_line(line)
except GSettingsSchemaConvertException, e:
raise GSettingsSchemaConvertException('%s:%s: %s' % (os.path.basename(self.file), current_line_nb, e))
return self.root
######################################
def read_gconf_schema(gconf_schema_file, default_schema_id):
gsettings_schema_root = GSettingsSchemaRoot()
default_schema_id_count = 0
gconfschemafile_node = ET.parse(gconf_schema_file).getroot()
for schemalist_node in gconfschemafile_node.findall('schemalist'):
for schema_node in schemalist_node.findall('schema'):
gconf_schema = GConfSchema(schema_node)
schemas_only = (gconf_schema.applyto is not None)
dirpath = gconf_schema.prefix
if dirpath[0] != '/':
raise GSettingsSchemaConvertException('Key \'%s\' has a relative path. There is no relative path in GSettings schemas.' % gconf_schema.applyto or gconf_schema.key)
# remove leading 'schemas/' for schemas-only keys
if schemas_only and dirpath.startswith('/schemas/'):
dirpath = dirpath[len('/schemas'):]
if len(dirpath) == 1:
raise GSettingsSchemaConvertException('Key \'%s\' is a toplevel key. Toplevel keys are not accepted in GSettings schemas.' % gconf_schema.applyto or gconf_schema.key)
# remove trailing slash because we'll split the string
if dirpath[-1] == '/':
dirpath = dirpath[:-1]
# and also remove leading slash when splitting
hierarchy = dirpath[1:].split('/')
# we don't want to put apps/ and desktop/ keys in the same schema,
# so we have a first step where we make sure to create a new schema
# to avoid this case if necessary
gsettings_schema = None
for schema in gsettings_schema_root.schemas:
if schemas_only:
schema_path = schema.path
else:
schema_path = schema._hacky_path
if dirpath.startswith(schema_path):
gsettings_schema = schema
break
if not gsettings_schema:
gsettings_schema = GSettingsSchema()
if default_schema_id:
gsettings_schema.id = default_schema_id
if default_schema_id_count > 0:
gsettings_schema.id += '.FIXME-%s' % default_schema_id_count
default_schema_id_count += 1
else:
gsettings_schema.id = 'FIXME'
if schemas_only:
gsettings_schema.path = '/' + hierarchy[0] + '/'
else:
gsettings_schema._hacky_path = '/' + hierarchy[0] + '/'
gsettings_schema_root.schemas.append(gsettings_schema)
# we create all the subdirs that lead to this key
gsettings_dir = gsettings_schema
for item in hierarchy[1:]:
subdir = None
for dir in gsettings_dir.dirs:
if dir.name == item:
subdir = dir
break
if not subdir:
subdir = GSettingsSchemaDir()
subdir.name = item
gsettings_dir.dirs.append(subdir)
gsettings_dir = subdir
# we have the final directory, so we can put the key there
gsettings_dir.keys.append(gconf_schema.get_gsettings_schema_key())
gsettings_schema_root.simplify()
return gsettings_schema_root
######################################
def main(args):
parser = optparse.OptionParser()
parser.add_option("-o", "--output", dest="output",
help="output file")
parser.add_option("-g", "--gconf", action="store_true", dest="gconf",
default=False, help="convert a gconf schema file")
parser.add_option("-i", "--schema-id", dest="schema_id",
help="default schema ID to use when converting gconf schema file")
parser.add_option("-s", "--simple", action="store_true", dest="simple",
default=False, help="use the simple schema format as output (only for gconf schema conversion)")
parser.add_option("-x", "--xml", action="store_true", dest="xml",
default=False, help="use the xml schema format as output")
parser.add_option("-f", "--force", action="store_true", dest="force",
default=False, help="overwrite output file if already existing")
(options, args) = parser.parse_args()
if len(args) < 1:
print >> sys.stderr, 'Need a filename to work on.'
return 1
elif len(args) > 1:
print >> sys.stderr, 'Too many arguments.'
return 1
if options.simple and options.xml:
print >> sys.stderr, 'Too many output formats requested.'
return 1
if not options.simple and not options.xml:
if options.gconf:
options.simple = True
else:
options.xml = True
if not options.gconf and options.schema_id:
print >> sys.stderr, 'Default schema ID can only be specified when converting a gconf schema.'
return 1
argfile = os.path.expanduser(args[0])
if not os.path.exists(argfile):
print >> sys.stderr, '\'%s\' does not exist.' % argfile
return 1
if options.output:
options.output = os.path.expanduser(options.output)
try:
if options.output and not options.force and os.path.exists(options.output):
raise GSettingsSchemaConvertException('\'%s\' already exists. Use --force to overwrite it.' % options.output)
if options.gconf:
try:
schema_root = read_gconf_schema(argfile, options.schema_id)
except SyntaxError, e:
raise GSettingsSchemaConvertException('\'%s\' does not look like a valid gconf schema file: %s' % (argfile, e))
else:
parser = SimpleSchemaParser(argfile)
schema_root = parser.parse()
if options.xml:
node = schema_root.get_xml_node()
tree = ET.ElementTree(node)
try:
output = ET.tostring(tree, pretty_print = True)
except TypeError:
# pretty_print only works with lxml
output = ET.tostring(tree)
else:
output = schema_root.get_simple_string()
if not options.output:
sys.stdout.write(output)
else:
try:
fout = open(options.output, 'w')
fout.write(output)
fout.close()
except GSettingsSchemaConvertException, e:
fout.close()
if os.path.exists(options.output):
os.unlink(options.output)
raise e
except GSettingsSchemaConvertException, e:
print >> sys.stderr, '%s' % e
return 1
return 0
if __name__ == '__main__':
try:
res = main(sys.argv)
sys.exit(res)
except KeyboardInterrupt:
pass