[style] Fixed pep8 warnings
[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" % (
287                             key,
288                             value,
289                             _ftype.__class__.__name__,
290                             e
291                         )
292                     )
293
294         return config
295
296     def prep_value(self, config):
297         """
298         the inverse of to_python method,
299         called just before serialization
300         """
301         for key, value in config.items():
302             _format = self.option_dict[key].get('format', None)
303             _ftype = self.types.get(_format, None)
304             if _ftype and hasattr(_ftype, 'get_prep_value'):
305                 try:
306                     config[key] = _ftype.get_prep_value(value)
307                 except BaseException, e:
308                     raise TypeCastException(
309                         "Could not serialize %s, %s, "
310                         "by format %s: %s" % (
311                             key,
312                             value,
313                             _ftype.__class__.__name__,
314                             e)
315                     )
316             else:
317                 config[key] = value
318         return config
319
320     # methods for adding configuration
321
322     def get_default_values(self):
323         """
324         return a config options from configuration defaults
325         """
326         defaults = {}
327         for key, value in self.items():
328             if 'default' in value:
329                 defaults[key] = value['default']
330         return copy.deepcopy(defaults)
331
332     def get_adaptor(self, format):
333         """
334         get specified format adaptor or
335         guess for a given filename
336         """
337         adaptor = self.adaptors.get(format, None)
338         if adaptor:
339             return adaptor
340
341         # not registered in adaptors dict, let's try all
342         for adaptor in self.adaptors.values():
343             if format in adaptor.extensions:
344                 return adaptor
345
346     def filename2format(self, filename):
347         extension = os.path.splitext(filename)[-1]
348         return extension.lstrip('.') or None
349
350     def serialize(self, filename, format=None, full=False):
351         if not format:
352             format = self._format
353         if not format:
354             format = self.filename2format(filename)
355         if not format:
356             raise Exception('Please specify a format')
357             # TODO: more specific exception type
358
359         adaptor = self.get_adaptor(format)
360         if not adaptor:
361             raise Exception("Adaptor not found for format: %s" % format)
362
363         config = copy.deepcopy(self.config)
364         serializable = self.prep_value(config)
365         adaptor.write(serializable, filename)
366
367         if self.mtime:
368             self.touch_mtime(filename)
369
370     def touch_mtime(self, filename):
371         mtime = self.mtime
372         os.utime(filename, (mtime, mtime))
373
374     def deserialize(self, string=None, fromfile=None, format=None):
375         """
376         load configuration from a file or string
377         """
378
379         def _try_deserialize():
380             if fromfile:
381                 with open(fromfile, 'r') as f:
382                     content = adaptor.read(f)
383             elif string:
384                 content = adaptor.read(string)
385             return content
386
387         # XXX cleanup this!
388
389         if fromfile:
390             leap_assert(os.path.exists(fromfile))
391             if not format:
392                 format = self.filename2format(fromfile)
393
394         if not format:
395             format = self._format
396         if format:
397             adaptor = self.get_adaptor(format)
398         else:
399             adaptor = None
400
401         if adaptor:
402             content = _try_deserialize()
403             return content
404
405         # no adaptor, let's try rest of adaptors
406
407         adaptors = self.adaptors[:]
408
409         if format:
410             adaptors.sort(
411                 key=lambda x: int(
412                     format in x.extensions),
413                 reverse=True)
414
415         for adaptor in adaptors:
416             content = _try_deserialize()
417         return content
418
419     def set_dirty(self):
420         self.dirty = True
421
422     def is_dirty(self):
423         return self.dirty
424
425     def load(self, *args, **kwargs):
426         """
427         load from string or file
428         if no string of fromfile option is given,
429         it will attempt to load from defaults
430         defined in the schema.
431         """
432         string = args[0] if args else None
433         fromfile = kwargs.get("fromfile", None)
434         mtime = kwargs.pop("mtime", None)
435         self.mtime = mtime
436         content = None
437
438         # start with defaults, so we can
439         # have partial values applied.
440         content = self.get_default_values()
441         if string and isinstance(string, str):
442             content = self.deserialize(string)
443
444         if not string and fromfile is not None:
445             # import ipdb;ipdb.set_trace()
446             content = self.deserialize(fromfile=fromfile)
447
448         if not content:
449             logger.error('no content could be loaded')
450             # XXX raise!
451             return
452
453         # lazy evaluation until first level of nesting
454         # to allow lambdas with context-dependant info
455         # like os.path.expanduser
456         for k, v in content.iteritems():
457             if callable(v):
458                 content[k] = v()
459
460         self.validate(content)
461         self.config = content
462         return True
463
464
465 def testmain():  # pragma: no cover
466
467     from tests import test_validation as t
468     import pprint
469
470     config = PluggableConfig(_format="json")
471     properties = copy.deepcopy(t.sample_spec)
472
473     config.options = properties
474     config.load(fromfile='data.json')
475
476     print 'config'
477     pprint.pprint(config.config)
478
479     config.serialize('/tmp/testserial.json')
480
481 if __name__ == "__main__":
482     testmain()