1#
2# Copyright (C) 2012 - 2018 Satoru SATOH <ssato @ redhat.com>
3# Copyright (C) 2019 Satoru SATOH <satoru.satoh @ gmail.com>
4# License: MIT
5#
6# pylint: disable=unused-argument
7r"""Abstract implementation of backend modules:
8
9Backend module must implement a parser class inherits :class:`Parser` or its
10children classes of this module and override all or some of the methods as
11needed:
12
13  - :meth:`load_from_string`: Load config from string
14  - :meth:`load_from_stream`: Load config from a file or file-like object
15  - :meth:`load_from_path`: Load config from file of given path
16  - :meth:`dump_to_string`: Dump config as a string
17  - :meth:`dump_to_stream`: Dump config to a file or file-like object
18  - :meth:`dump_to_path`: Dump config to a file of given path
19"""
20from __future__ import absolute_import
21
22import functools
23import logging
24import os
25
26import anyconfig.compat
27import anyconfig.globals
28import anyconfig.models.processor
29import anyconfig.utils
30
31
32LOGGER = logging.getLogger(__name__)
33TEXT_FILE = True
34
35
36def ensure_outdir_exists(filepath):
37    """
38    Make dir to dump 'filepath' if that dir does not exist.
39
40    :param filepath: path of file to dump
41    """
42    outdir = os.path.dirname(filepath)
43
44    if outdir and not os.path.exists(outdir):
45        LOGGER.debug("Making output dir: %s", outdir)
46        os.makedirs(outdir)
47
48
49def to_method(func):
50    """
51    Lift :func:`func` to a method; it will be called with the first argument
52    'self' ignored.
53
54    :param func: Any callable object
55    """
56    @functools.wraps(func)
57    def wrapper(*args, **kwargs):
58        """Wrapper function.
59        """
60        return func(*args[1:], **kwargs)
61
62    return wrapper
63
64
65def _not_implemented(*args, **kwargs):
66    """
67    Utility function to raise NotImplementedError.
68    """
69    raise NotImplementedError()
70
71
72class TextFilesMixin(object):
73    """Mixin class to open configuration files as a plain text.
74
75    Arguments of :func:`open` is different depends on python versions.
76
77    - python 2: https://docs.python.org/2/library/functions.html#open
78    - python 3: https://docs.python.org/3/library/functions.html#open
79    """
80    _open_flags = ('r', 'w')
81
82    @classmethod
83    def ropen(cls, filepath, **kwargs):
84        """
85        :param filepath: Path to file to open to read data
86        """
87        return open(filepath, cls._open_flags[0], **kwargs)
88
89    @classmethod
90    def wopen(cls, filepath, **kwargs):
91        """
92        :param filepath: Path to file to open to write data to
93        """
94        return open(filepath, cls._open_flags[1], **kwargs)
95
96
97class BinaryFilesMixin(TextFilesMixin):
98    """Mixin class to open binary (byte string) configuration files.
99    """
100    _open_flags = ('rb', 'wb')
101
102
103class LoaderMixin(object):
104    """
105    Mixin class to load data.
106
107    Inherited classes must implement the following methods.
108
109    - :meth:`load_from_string`: Load config from string
110    - :meth:`load_from_stream`: Load config from a file or file-like object
111    - :meth:`load_from_path`: Load config from file of given path
112
113    Member variables:
114
115    - _load_opts: Backend specific options on load
116    - _ordered: True if the parser keep the order of items by default
117    - _allow_primitives: True if the parser.load* may return objects of
118      primitive data types other than mapping types such like JSON parser
119    - _dict_opts: Backend options to customize dict class to make results
120    """
121    _load_opts = []
122    _ordered = False
123    _allow_primitives = False
124    _dict_opts = []
125
126    @classmethod
127    def ordered(cls):
128        """
129        :return: True if parser can keep the order of keys else False.
130        """
131        return cls._ordered
132
133    @classmethod
134    def allow_primitives(cls):
135        """
136        :return:
137            True if the parser.load* may return objects of primitive data types
138            other than mapping types such like JSON parser
139        """
140        return cls._allow_primitives
141
142    @classmethod
143    def dict_options(cls):
144        """
145        :return: List of dict factory options
146        """
147        return cls._dict_opts
148
149    def _container_factory(self, **options):
150        """
151        The order of prirorities are ac_dict, backend specific dict class
152        option, ac_ordered.
153
154        :param options: Keyword options may contain 'ac_ordered'.
155        :return: Factory (class or function) to make an container.
156        """
157        ac_dict = options.get("ac_dict", False)
158        _dicts = [x for x in (options.get(o) for o in self.dict_options())
159                  if x]
160
161        if self.dict_options() and ac_dict and callable(ac_dict):
162            return ac_dict  # Higher priority than ac_ordered.
163        if _dicts and callable(_dicts[0]):
164            return _dicts[0]
165        if self.ordered() and options.get("ac_ordered", False):
166            return anyconfig.compat.OrderedDict
167
168        return dict
169
170    def _load_options(self, container, **options):
171        """
172        Select backend specific loading options.
173        """
174        # Force set dict option if available in backend. For example,
175        # options["object_hook"] will be OrderedDict if 'container' was
176        # OrderedDict in JSON backend.
177        for opt in self.dict_options():
178            options.setdefault(opt, container)
179
180        return anyconfig.utils.filter_options(self._load_opts, options)
181
182    def load_from_string(self, content, container, **kwargs):
183        """
184        Load config from given string 'content'.
185
186        :param content: Config content string
187        :param container: callble to make a container object later
188        :param kwargs: optional keyword parameters to be sanitized :: dict
189
190        :return: Dict-like object holding config parameters
191        """
192        _not_implemented(self, content, container, **kwargs)
193
194    def load_from_path(self, filepath, container, **kwargs):
195        """
196        Load config from given file path 'filepath`.
197
198        :param filepath: Config file path
199        :param container: callble to make a container object later
200        :param kwargs: optional keyword parameters to be sanitized :: dict
201
202        :return: Dict-like object holding config parameters
203        """
204        _not_implemented(self, filepath, container, **kwargs)
205
206    def load_from_stream(self, stream, container, **kwargs):
207        """
208        Load config from given file like object 'stream`.
209
210        :param stream:  Config file or file like object
211        :param container: callble to make a container object later
212        :param kwargs: optional keyword parameters to be sanitized :: dict
213
214        :return: Dict-like object holding config parameters
215        """
216        _not_implemented(self, stream, container, **kwargs)
217
218    def loads(self, content, **options):
219        """
220        Load config from given string 'content' after some checks.
221
222        :param content:  Config file content
223        :param options:
224            options will be passed to backend specific loading functions.
225            please note that options have to be sanitized w/
226            :func:`anyconfig.utils.filter_options` later to filter out options
227            not in _load_opts.
228
229        :return: dict or dict-like object holding configurations
230        """
231        container = self._container_factory(**options)
232        if not content or content is None:
233            return container()
234
235        options = self._load_options(container, **options)
236        return self.load_from_string(content, container, **options)
237
238    def load(self, ioi, ac_ignore_missing=False, **options):
239        """
240        Load config from a file path or a file / file-like object which 'ioi'
241        refering after some checks.
242
243        :param ioi:
244            'anyconfig.globals.IOInfo' namedtuple object provides various info
245            of input object to load data from
246
247        :param ac_ignore_missing:
248            Ignore and just return empty result if given `ioi` object does not
249            exist in actual.
250        :param options:
251            options will be passed to backend specific loading functions.
252            please note that options have to be sanitized w/
253            :func:`anyconfig.utils.filter_options` later to filter out options
254            not in _load_opts.
255
256        :return: dict or dict-like object holding configurations
257        """
258        container = self._container_factory(**options)
259        options = self._load_options(container, **options)
260
261        if not ioi:
262            return container()
263
264        if anyconfig.utils.is_stream_ioinfo(ioi):
265            cnf = self.load_from_stream(ioi.src, container, **options)
266        else:
267            if ac_ignore_missing and not os.path.exists(ioi.path):
268                return container()
269
270            cnf = self.load_from_path(ioi.path, container, **options)
271
272        return cnf
273
274
275class DumperMixin(object):
276    """
277    Mixin class to dump data.
278
279    Inherited classes must implement the following methods.
280
281    - :meth:`dump_to_string`: Dump config as a string
282    - :meth:`dump_to_stream`: Dump config to a file or file-like object
283    - :meth:`dump_to_path`: Dump config to a file of given path
284
285    Member variables:
286
287    - _dump_opts: Backend specific options on dump
288    """
289    _dump_opts = []
290
291    def dump_to_string(self, cnf, **kwargs):
292        """
293        Dump config 'cnf' to a string.
294
295        :param cnf: Configuration data to dump
296        :param kwargs: optional keyword parameters to be sanitized :: dict
297
298        :return: string represents the configuration
299        """
300        _not_implemented(self, cnf, **kwargs)
301
302    def dump_to_path(self, cnf, filepath, **kwargs):
303        """
304        Dump config 'cnf' to a file 'filepath'.
305
306        :param cnf: Configuration data to dump
307        :param filepath: Config file path
308        :param kwargs: optional keyword parameters to be sanitized :: dict
309        """
310        _not_implemented(self, cnf, filepath, **kwargs)
311
312    def dump_to_stream(self, cnf, stream, **kwargs):
313        """
314        Dump config 'cnf' to a file-like object 'stream'.
315
316        TODO: How to process socket objects same as file objects ?
317
318        :param cnf: Configuration data to dump
319        :param stream:  Config file or file like object
320        :param kwargs: optional keyword parameters to be sanitized :: dict
321        """
322        _not_implemented(self, cnf, stream, **kwargs)
323
324    def dumps(self, cnf, **kwargs):
325        """
326        Dump config 'cnf' to a string.
327
328        :param cnf: Configuration data to dump
329        :param kwargs: optional keyword parameters to be sanitized :: dict
330
331        :return: string represents the configuration
332        """
333        kwargs = anyconfig.utils.filter_options(self._dump_opts, kwargs)
334        return self.dump_to_string(cnf, **kwargs)
335
336    def dump(self, cnf, ioi, **kwargs):
337        """
338        Dump config 'cnf' to output object of which 'ioi' refering.
339
340        :param cnf: Configuration data to dump
341        :param ioi:
342            an 'anyconfig.globals.IOInfo' namedtuple object provides various
343            info of input object to load data from
344
345        :param kwargs: optional keyword parameters to be sanitized :: dict
346        :raises IOError, OSError, AttributeError: When dump failed.
347        """
348        kwargs = anyconfig.utils.filter_options(self._dump_opts, kwargs)
349
350        if anyconfig.utils.is_stream_ioinfo(ioi):
351            self.dump_to_stream(cnf, ioi.src, **kwargs)
352        else:
353            ensure_outdir_exists(ioi.path)
354            self.dump_to_path(cnf, ioi.path, **kwargs)
355
356
357class Parser(TextFilesMixin, LoaderMixin, DumperMixin,
358             anyconfig.models.processor.Processor):
359    """
360    Abstract parser to provide basic implementation of some methods, interfaces
361    and members.
362
363    - _type: Parser type indicate which format it supports
364    - _priority: Priority to select it if there are other parsers of same type
365    - _extensions: File extensions of formats it supports
366    - _open_flags: Opening flags to read and write files
367
368    .. seealso:: the doc of :class:`anyconfig.models.processor.Processor`
369    """
370    pass
371
372
373class FromStringLoaderMixin(LoaderMixin):
374    """
375    Abstract config parser provides a method to load configuration from string
376    content to help implement parser of which backend lacks of such function.
377
378    Parser classes inherit this class have to override the method
379    :meth:`load_from_string` at least.
380    """
381    def load_from_stream(self, stream, container, **kwargs):
382        """
383        Load config from given stream 'stream'.
384
385        :param stream: Config file or file-like object
386        :param container: callble to make a container object later
387        :param kwargs: optional keyword parameters to be sanitized :: dict
388
389        :return: Dict-like object holding config parameters
390        """
391        return self.load_from_string(stream.read(), container, **kwargs)
392
393    def load_from_path(self, filepath, container, **kwargs):
394        """
395        Load config from given file path 'filepath'.
396
397        :param filepath: Config file path
398        :param container: callble to make a container object later
399        :param kwargs: optional keyword parameters to be sanitized :: dict
400
401        :return: Dict-like object holding config parameters
402        """
403        with self.ropen(filepath) as inp:
404            return self.load_from_stream(inp, container, **kwargs)
405
406
407class FromStreamLoaderMixin(LoaderMixin):
408    """
409    Abstract config parser provides a method to load configuration from string
410    content to help implement parser of which backend lacks of such function.
411
412    Parser classes inherit this class have to override the method
413    :meth:`load_from_stream` at least.
414    """
415    def load_from_string(self, content, container, **kwargs):
416        """
417        Load config from given string 'cnf_content'.
418
419        :param content: Config content string
420        :param container: callble to make a container object later
421        :param kwargs: optional keyword parameters to be sanitized :: dict
422
423        :return: Dict-like object holding config parameters
424        """
425        return self.load_from_stream(anyconfig.compat.StringIO(content),
426                                     container, **kwargs)
427
428    def load_from_path(self, filepath, container, **kwargs):
429        """
430        Load config from given file path 'filepath'.
431
432        :param filepath: Config file path
433        :param container: callble to make a container object later
434        :param kwargs: optional keyword parameters to be sanitized :: dict
435
436        :return: Dict-like object holding config parameters
437        """
438        with self.ropen(filepath) as inp:
439            return self.load_from_stream(inp, container, **kwargs)
440
441
442class ToStringDumperMixin(DumperMixin):
443    """
444    Abstract config parser provides a method to dump configuration to a file or
445    file-like object (stream) and a file of given path to help implement parser
446    of which backend lacks of such functions.
447
448    Parser classes inherit this class have to override the method
449    :meth:`dump_to_string` at least.
450    """
451    def dump_to_path(self, cnf, filepath, **kwargs):
452        """
453        Dump config 'cnf' to a file 'filepath'.
454
455        :param cnf: Configuration data to dump
456        :param filepath: Config file path
457        :param kwargs: optional keyword parameters to be sanitized :: dict
458        """
459        with self.wopen(filepath) as out:
460            out.write(self.dump_to_string(cnf, **kwargs))
461
462    def dump_to_stream(self, cnf, stream, **kwargs):
463        """
464        Dump config 'cnf' to a file-like object 'stream'.
465
466        TODO: How to process socket objects same as file objects ?
467
468        :param cnf: Configuration data to dump
469        :param stream:  Config file or file like object
470        :param kwargs: optional keyword parameters to be sanitized :: dict
471        """
472        stream.write(self.dump_to_string(cnf, **kwargs))
473
474
475class ToStreamDumperMixin(DumperMixin):
476    """
477    Abstract config parser provides methods to dump configuration to a string
478    content or a file of given path to help implement parser of which backend
479    lacks of such functions.
480
481    Parser classes inherit this class have to override the method
482    :meth:`dump_to_stream` at least.
483    """
484    def dump_to_string(self, cnf, **kwargs):
485        """
486        Dump config 'cnf' to a string.
487
488        :param cnf: Configuration data to dump
489        :param kwargs: optional keyword parameters to be sanitized :: dict
490
491        :return: Dict-like object holding config parameters
492        """
493        stream = anyconfig.compat.StringIO()
494        self.dump_to_stream(cnf, stream, **kwargs)
495        return stream.getvalue()
496
497    def dump_to_path(self, cnf, filepath, **kwargs):
498        """
499        Dump config 'cnf' to a file 'filepath`.
500
501        :param cnf: Configuration data to dump
502        :param filepath: Config file path
503        :param kwargs: optional keyword parameters to be sanitized :: dict
504        """
505        with self.wopen(filepath) as out:
506            self.dump_to_stream(cnf, out, **kwargs)
507
508
509class StringParser(Parser, FromStringLoaderMixin, ToStringDumperMixin):
510    """
511    Abstract parser based on :meth:`load_from_string` and
512    :meth:`dump_to_string`.
513
514    Parser classes inherit this class must define these methods.
515    """
516    pass
517
518
519class StreamParser(Parser, FromStreamLoaderMixin, ToStreamDumperMixin):
520    """
521    Abstract parser based on :meth:`load_from_stream` and
522    :meth:`dump_to_stream`.
523
524    Parser classes inherit this class must define these methods.
525    """
526    pass
527
528
529def load_with_fn(load_fn, content_or_strm, container, allow_primitives=False,
530                 **options):
531    """
532    Load data from given string or stream 'content_or_strm'.
533
534    :param load_fn: Callable to load data
535    :param content_or_strm: data content or stream provides it
536    :param container: callble to make a container object
537    :param allow_primitives:
538        True if the parser.load* may return objects of primitive data types
539        other than mapping types such like JSON parser
540    :param options: keyword options passed to 'load_fn'
541
542    :return: container object holding data
543    """
544    ret = load_fn(content_or_strm, **options)
545    if anyconfig.utils.is_dict_like(ret):
546        return container() if (ret is None or not ret) else container(ret)
547
548    return ret if allow_primitives else container(ret)
549
550
551def dump_with_fn(dump_fn, data, stream, **options):
552    """
553    Dump 'data' to a string if 'stream' is None, or dump 'data' to a file or
554    file-like object 'stream'.
555
556    :param dump_fn: Callable to dump data
557    :param data: Data to dump
558    :param stream:  File or file like object or None
559    :param options: optional keyword parameters
560
561    :return: String represents data if stream is None or None
562    """
563    if stream is None:
564        return dump_fn(data, **options)
565
566    return dump_fn(data, stream, **options)
567
568
569class StringStreamFnParser(Parser, FromStreamLoaderMixin, ToStreamDumperMixin):
570    """
571    Abstract parser utilizes load and dump functions each backend module
572    provides such like json.load{,s} and json.dump{,s} in JSON backend.
573
574    Parser classes inherit this class must define the followings.
575
576    - _load_from_string_fn: Callable to load data from string
577    - _load_from_stream_fn: Callable to load data from stream (file object)
578    - _dump_to_string_fn: Callable to dump data to string
579    - _dump_to_stream_fn: Callable to dump data to stream (file object)
580
581    .. note::
582       Callables have to be wrapped with :func:`to_method` to make 'self'
583       passed to the methods created from them ignoring it.
584
585    :seealso: :class:`anyconfig.backend.json.Parser`
586    """
587    _load_from_string_fn = None
588    _load_from_stream_fn = None
589    _dump_to_string_fn = None
590    _dump_to_stream_fn = None
591
592    def load_from_string(self, content, container, **options):
593        """
594        Load configuration data from given string 'content'.
595
596        :param content: Configuration string
597        :param container: callble to make a container object
598        :param options: keyword options passed to '_load_from_string_fn'
599
600        :return: container object holding the configuration data
601        """
602        return load_with_fn(self._load_from_string_fn, content, container,
603                            allow_primitives=self.allow_primitives(),
604                            **options)
605
606    def load_from_stream(self, stream, container, **options):
607        """
608        Load data from given stream 'stream'.
609
610        :param stream: Stream provides configuration data
611        :param container: callble to make a container object
612        :param options: keyword options passed to '_load_from_stream_fn'
613
614        :return: container object holding the configuration data
615        """
616        return load_with_fn(self._load_from_stream_fn, stream, container,
617                            allow_primitives=self.allow_primitives(),
618                            **options)
619
620    def dump_to_string(self, cnf, **kwargs):
621        """
622        Dump config 'cnf' to a string.
623
624        :param cnf: Configuration data to dump
625        :param kwargs: optional keyword parameters to be sanitized :: dict
626
627        :return: string represents the configuration
628        """
629        return dump_with_fn(self._dump_to_string_fn, cnf, None, **kwargs)
630
631    def dump_to_stream(self, cnf, stream, **kwargs):
632        """
633        Dump config 'cnf' to a file-like object 'stream'.
634
635        TODO: How to process socket objects same as file objects ?
636
637        :param cnf: Configuration data to dump
638        :param stream:  Config file or file like object
639        :param kwargs: optional keyword parameters to be sanitized :: dict
640        """
641        dump_with_fn(self._dump_to_stream_fn, cnf, stream, **kwargs)
642
643# vim:sw=4:ts=4:et:
644