8535fa6bf6e138b8519a369420e14f41553dbbd3
[leap_pycommon.git] / src / leap / common / config / pluggableconfig.py
1 # -*- coding: utf-8 -*-
2 # pluggableconfig.py
3 # Copyright (C) 2013 LEAP
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation, either version 3 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
18 """
19 generic configuration handlers
20 """
21 import copy
22 import json
23 import logging
24 import os
25 import time
26 import urlparse
27
28 import jsonschema
29
30 #from leap.base.util.translations import LEAPTranslatable
31 from leap.common.check import leap_assert
32
33
34 logger = logging.getLogger(__name__)
35
36
37 __all__ = ['PluggableConfig',
38            'adaptors',
39            'types',
40            'UnknownOptionException',
41            'MissingValueException',
42            'ConfigurationProviderException',
43            'TypeCastException']
44
45 # exceptions
46
47
48 class UnknownOptionException(Exception):
49     """exception raised when a non-configuration
50     value is present in the configuration"""
51
52
53 class MissingValueException(Exception):
54     """exception raised when a required value is missing"""
55
56
57 class ConfigurationProviderException(Exception):
58     """exception raised when a configuration provider is missing, etc"""
59
60
61 class TypeCastException(Exception):
62     """exception raised when a
63     configuration item cannot be coerced to a type"""
64
65
66 class ConfigAdaptor(object):
67     """
68     abstract base class for config adaotors for
69     serialization/deserialization and custom validation
70     and type casting.
71     """
72     def read(self, filename):
73         raise NotImplementedError("abstract base class")
74
75     def write(self, config, filename):
76         with open(filename, 'w') as f:
77             self._write(f, config)
78
79     def _write(self, fp, config):
80         raise NotImplementedError("abstract base class")
81
82     def validate(self, config, schema):
83         raise NotImplementedError("abstract base class")
84
85
86 adaptors = {}
87
88
89 class JSONSchemaEncoder(json.JSONEncoder):
90     """
91     custom default encoder that
92     casts python objects to json objects for
93     the schema validation
94     """
95     def default(self, obj):
96         if obj is str:
97             return 'string'
98         if obj is unicode:
99             return 'string'
100         if obj is int:
101             return 'integer'
102         if obj is list:
103             return 'array'
104         if obj is dict:
105             return 'object'
106         if obj is bool:
107             return 'boolean'
108
109
110 class JSONAdaptor(ConfigAdaptor):
111     indent = 2
112     extensions = ['json']
113
114     def read(self, _from):
115         if isinstance(_from, file):
116             _from_string = _from.read()
117         if isinstance(_from, str):
118             _from_string = _from
119         return json.loads(_from_string)
120
121     def _write(self, fp, config):
122         fp.write(json.dumps(config,
123                  indent=self.indent,
124                  sort_keys=True))
125
126     def validate(self, config, schema_obj):
127         schema_json = JSONSchemaEncoder().encode(schema_obj)
128         schema = json.loads(schema_json)
129         jsonschema.validate(config, schema)
130
131
132 adaptors['json'] = JSONAdaptor()
133
134 #
135 # Adaptors
136 #
137 # Allow to apply a predefined set of types to the
138 # specs, so it checks the validity of formats and cast it
139 # to proper python types.
140
141 # TODO:
142 # - HTTPS uri
143
144
145 class DateType(object):
146     fmt = '%Y-%m-%d'
147
148     def to_python(self, data):
149         return time.strptime(data, self.fmt)
150
151     def get_prep_value(self, data):
152         return time.strftime(self.fmt, data)
153
154
155 class TranslatableType(object):
156     """
157     a type that casts to LEAPTranslatable objects.
158     Used for labels we get from providers and stuff.
159     """
160
161     def to_python(self, data):
162         # TODO: add translatable
163         return data  # LEAPTranslatable(data)
164
165     # needed? we already have an extended dict...
166     #def get_prep_value(self, data):
167         #return dict(data)
168
169
170 class URIType(object):
171
172     def to_python(self, data):
173         parsed = urlparse.urlparse(data)
174         if not parsed.scheme:
175             raise TypeCastException("uri %s has no schema" % data)
176         return parsed.geturl()
177
178     def get_prep_value(self, data):
179         return data
180
181
182 class HTTPSURIType(object):
183
184     def to_python(self, data):
185         parsed = urlparse.urlparse(data)
186         if not parsed.scheme:
187             raise TypeCastException("uri %s has no schema" % data)
188         if parsed.scheme != "https":
189             raise TypeCastException(
190                 "uri %s does not has "
191                 "https schema" % data)
192         return parsed.geturl()
193
194     def get_prep_value(self, data):
195         return data
196
197
198 types = {
199     'date': DateType(),
200     'uri': URIType(),
201     'https-uri': HTTPSURIType(),
202     'translatable': TranslatableType(),
203 }
204
205
206 class PluggableConfig(object):
207
208     options = {}
209
210     def __init__(self,
211                  adaptors=adaptors,
212                  types=types,
213                  format=None):
214
215         self.config = {}
216         self.adaptors = adaptors
217         self.types = types
218         self._format = format
219         self.mtime = None
220         self.dirty = False
221
222     @property
223     def option_dict(self):
224         if hasattr(self, 'options') and isinstance(self.options, dict):
225             return self.options.get('properties', None)
226
227     def items(self):
228         """
229         act like an iterator
230         """
231         if isinstance(self.option_dict, dict):
232             return self.option_dict.items()
233         return self.options
234
235     def validate(self, config, format=None):
236         """
237         validate config
238         """
239         schema = self.options
240         if format is None:
241             format = self._format
242
243         if format:
244             adaptor = self.get_adaptor(self._format)
245             adaptor.validate(config, schema)
246         else:
247             # we really should make format mandatory...
248             logger.error('no format passed to validate')
249
250         # first round of validation is ok.
251         # now we proceed to cast types if any specified.
252         self.to_python(config)
253
254     def to_python(self, config):
255         """
256         cast types following first type and then format indications.
257         """
258         unseen_options = [i for i in config if i not in self.option_dict]
259         if unseen_options:
260             raise UnknownOptionException(
261                 "Unknown options: %s" % ', '.join(unseen_options))
262
263         for key, value in config.items():
264             _type = self.option_dict[key].get('type')
265             if _type is None and 'default' in self.option_dict[key]:
266                 _type = type(self.option_dict[key]['default'])
267             if _type is not None:
268                 tocast = True
269                 if not callable(_type) and isinstance(value, _type):
270                     tocast = False
271                 if tocast:
272                     try:
273                         config[key] = _type(value)
274                     except BaseException, e:
275                         raise TypeCastException(
276                             "Could not coerce %s, %s, "
277                             "to type %s: %s" % (key, value, _type.__name__, e))
278             _format = self.option_dict[key].get('format', None)
279             _ftype = self.types.get(_format, None)
280             if _ftype:
281                 try:
282                     config[key] = _ftype.to_python(value)
283                 except BaseException, e:
284                     raise TypeCastException(
285                         "Could not coerce %s, %s, "
286                         "to format %s: %s" % (key, value,
287                         _ftype.__class__.__name__,
288                         e))
289
290         return config
291
292     def prep_value(self, config):
293         """
294         the inverse of to_python method,
295         called just before serialization
296         """
297         for key, value in config.items():
298             _format = self.option_dict[key].get('format', None)
299             _ftype = self.types.get(_format, None)
300             if _ftype and hasattr(_ftype, 'get_prep_value'):
301                 try:
302                     config[key] = _ftype.get_prep_value(value)
303                 except BaseException, e:
304                     raise TypeCastException(
305                         "Could not serialize %s, %s, "
306                         "by format %s: %s" % (key, value,
307                         _ftype.__class__.__name__,
308                         e))
309             else:
310                 config[key] = value
311         return config
312
313     # methods for adding configuration
314
315     def get_default_values(self):
316         """
317         return a config options from configuration defaults
318         """
319         defaults = {}
320         for key, value in self.items():
321             if 'default' in value:
322                 defaults[key] = value['default']
323         return copy.deepcopy(defaults)
324
325     def get_adaptor(self, format):
326         """
327         get specified format adaptor or
328         guess for a given filename
329         """
330         adaptor = self.adaptors.get(format, None)
331         if adaptor:
332             return adaptor
333
334         # not registered in adaptors dict, let's try all
335         for adaptor in self.adaptors.values():
336             if format in adaptor.extensions:
337                 return adaptor
338
339     def filename2format(self, filename):
340         extension = os.path.splitext(filename)[-1]
341         return extension.lstrip('.') or None
342
343     def serialize(self, filename, format=None, full=False):
344         if not format:
345             format = self._format
346         if not format:
347             format = self.filename2format(filename)
348         if not format:
349             raise Exception('Please specify a format')
350             # TODO: more specific exception type
351
352         adaptor = self.get_adaptor(format)
353         if not adaptor:
354             raise Exception("Adaptor not found for format: %s" % format)
355
356         config = copy.deepcopy(self.config)
357         serializable = self.prep_value(config)
358         adaptor.write(serializable, filename)
359
360         if self.mtime:
361             self.touch_mtime(filename)
362
363     def touch_mtime(self, filename):
364         mtime = self.mtime
365         os.utime(filename, (mtime, mtime))
366
367     def deserialize(self, string=None, fromfile=None, format=None):
368         """
369         load configuration from a file or string
370         """
371
372         def _try_deserialize():
373             if fromfile:
374                 with open(fromfile, 'r') as f:
375                     content = adaptor.read(f)
376             elif string:
377                 content = adaptor.read(string)
378             return content
379
380         # XXX cleanup this!
381
382         if fromfile:
383             leap_assert(os.path.exists(fromfile))
384             if not format:
385                 format = self.filename2format(fromfile)
386
387         if not format:
388             format = self._format
389         if format:
390             adaptor = self.get_adaptor(format)
391         else:
392             adaptor = None
393
394         if adaptor:
395             content = _try_deserialize()
396             return content
397
398         # no adaptor, let's try rest of adaptors
399
400         adaptors = self.adaptors[:]
401
402         if format:
403             adaptors.sort(
404                 key=lambda x: int(
405                     format in x.extensions),
406                 reverse=True)
407
408         for adaptor in adaptors:
409             content = _try_deserialize()
410         return content
411
412     def set_dirty(self):
413         self.dirty = True
414
415     def is_dirty(self):
416         return self.dirty
417
418     def load(self, *args, **kwargs):
419         """
420         load from string or file
421         if no string of fromfile option is given,
422         it will attempt to load from defaults
423         defined in the schema.
424         """
425         string = args[0] if args else None
426         fromfile = kwargs.get("fromfile", None)
427         mtime = kwargs.pop("mtime", None)
428         self.mtime = mtime
429         content = None
430
431         # start with defaults, so we can
432         # have partial values applied.
433         content = self.get_default_values()
434         if string and isinstance(string, str):
435             content = self.deserialize(string)
436
437         if not string and fromfile is not None:
438             #import ipdb;ipdb.set_trace()
439             content = self.deserialize(fromfile=fromfile)
440
441         if not content:
442             logger.error('no content could be loaded')
443             # XXX raise!
444             return
445
446         # lazy evaluation until first level of nesting
447         # to allow lambdas with context-dependant info
448         # like os.path.expanduser
449         for k, v in content.iteritems():
450             if callable(v):
451                 content[k] = v()
452
453         self.validate(content)
454         self.config = content
455         return True
456
457
458 def testmain():  # pragma: no cover
459
460     from tests import test_validation as t
461     import pprint
462
463     config = PluggableConfig(_format="json")
464     properties = copy.deepcopy(t.sample_spec)
465
466     config.options = properties
467     config.load(fromfile='data.json')
468
469     print 'config'
470     pprint.pprint(config.config)
471
472     config.serialize('/tmp/testserial.json')
473
474 if __name__ == "__main__":
475     testmain()