1# -*- coding: utf-8 -*-
2# pylint: disable=missing-docstring
3"""
4Provides generic utility classes for the :class:`parse.Parser` class.
5"""
6
7from __future__ import absolute_import
8from collections import namedtuple
9import parse
10import six
11
12
13# -- HELPER-CLASS: For format part in a Field.
14# REQUIRES: Python 2.6 or newer.
15# pylint: disable=redefined-builtin, too-many-arguments
16FormatSpec = namedtuple("FormatSpec",
17                        ["type", "width", "zero", "align", "fill", "precision"])
18
19def make_format_spec(type=None, width="", zero=False, align=None, fill=None,
20                     precision=None):
21    return FormatSpec(type, width, zero, align, fill, precision)
22# pylint: enable=redefined-builtin
23
24class Field(object):
25    """
26    Provides a ValueObject for a Field in a parse expression.
27
28    Examples:
29        * "{}"
30        * "{name}"
31        * "{:format}"
32        * "{name:format}"
33
34    Format specification: [[fill]align][0][width][.precision][type]
35    """
36    # pylint: disable=redefined-builtin
37    ALIGN_CHARS = '<>=^'
38
39    def __init__(self, name="", format=None):
40        self.name = name
41        self.format = format
42        self._format_spec = None
43
44    def set_format(self, format):
45        self.format = format
46        self._format_spec = None
47
48    @property
49    def has_format(self):
50        return bool(self.format)
51
52    @property
53    def format_spec(self):
54        if not self._format_spec and self.format:
55            self._format_spec = self.extract_format_spec(self.format)
56        return self._format_spec
57
58    def __str__(self):
59        name = self.name or ""
60        if self.has_format:
61            return "{%s:%s}" % (name, self.format)
62        return "{%s}" % name
63
64    def __eq__(self, other):
65        if isinstance(other, Field):
66            format1 = self.format or ""
67            format2 = other.format or ""
68            return (self.name == other.name) and (format1 == format2)
69        elif isinstance(other, six.string_types):
70            return str(self) == other
71        else:
72            raise ValueError(other)
73
74    def __ne__(self, other):
75        return not self.__eq__(other)
76
77    @staticmethod
78    def make_format(format_spec):
79        """Build format string from a format specification.
80
81        :param format_spec:     Format specification (as FormatSpec object).
82        :return: Composed format (as string).
83        """
84        fill = ''
85        align = ''
86        zero = ''
87        width = format_spec.width
88        if format_spec.align:
89            align = format_spec.align[0]
90            if format_spec.fill:
91                fill = format_spec.fill[0]
92        if format_spec.zero:
93            zero = '0'
94
95        precision_part = ""
96        if format_spec.precision:
97            precision_part = ".%s" % format_spec.precision
98
99        # -- FORMAT-SPEC: [[fill]align][0][width][.precision][type]
100        return "%s%s%s%s%s%s" % (fill, align, zero, width,
101                                 precision_part, format_spec.type)
102
103
104    @classmethod
105    def extract_format_spec(cls, format):
106        """Pull apart the format: [[fill]align][0][width][.precision][type]"""
107        # -- BASED-ON: parse.extract_format()
108        # pylint: disable=redefined-builtin, unsubscriptable-object
109        if not format:
110            raise ValueError("INVALID-FORMAT: %s (empty-string)" % format)
111
112        orig_format = format
113        fill = align = None
114        if format[0] in cls.ALIGN_CHARS:
115            align = format[0]
116            format = format[1:]
117        elif len(format) > 1 and format[1] in cls.ALIGN_CHARS:
118            fill = format[0]
119            align = format[1]
120            format = format[2:]
121
122        zero = False
123        if format and format[0] == '0':
124            zero = True
125            format = format[1:]
126
127        width = ''
128        while format:
129            if not format[0].isdigit():
130                break
131            width += format[0]
132            format = format[1:]
133
134        precision = None
135        if format.startswith('.'):
136            # Precision isn't needed but we need to capture it so that
137            # the ValueError isn't raised.
138            format = format[1:]  # drop the '.'
139            precision = ''
140            while format:
141                if not format[0].isdigit():
142                    break
143                precision += format[0]
144                format = format[1:]
145
146        # the rest is the type, if present
147        type = format
148        if not type:
149            raise ValueError("INVALID-FORMAT: %s (without type)" % orig_format)
150        return FormatSpec(type, width, zero, align, fill, precision)
151
152
153class FieldParser(object):
154    """
155    Utility class that parses/extracts fields in parse expressions.
156    """
157
158    @classmethod
159    def parse(cls, text):
160        if not (text.startswith('{') and text.endswith('}')):
161            message = "FIELD-SCHEMA MISMATCH: text='%s' (missing braces)" % text
162            raise ValueError(message)
163
164        # first: lose the braces
165        text = text[1:-1]
166        if ':' in text:
167            # -- CASE: Typed field with format.
168            name, format_ = text.split(':')
169        else:
170            name = text
171            format_ = None
172        return Field(name, format_)
173
174    @classmethod
175    def extract_fields(cls, schema):
176        """Extract fields in a parse expression schema.
177
178        :param schema: Parse expression schema/format to use (as string).
179        :return: Generator for fields in schema (as Field objects).
180        """
181        # -- BASED-ON: parse.Parser._generate_expression()
182        for part in parse.PARSE_RE.split(schema):
183            if not part or part == '{{' or part == '}}':
184                continue
185            elif part[0] == '{':
186                # this will be a braces-delimited field to handle
187                yield cls.parse(part)
188
189    @classmethod
190    def extract_types(cls, schema):
191        """Extract types (names) for typed fields (with format/type part).
192
193        :param schema: Parser schema/format to use.
194        :return: Generator for type names (as string).
195        """
196        for field in cls.extract_fields(schema):
197            if field.has_format:
198                yield field.format_spec.type
199