• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

zopefoundation / zope.i18n / 16399678496

27 Sep 2024 06:55AM UTC coverage: 98.997% (-0.02%) from 99.018%
16399678496

push

github

icemac
Back to development: 5.3

747 of 784 branches covered (95.28%)

Branch coverage included in aggregate %.

3203 of 3206 relevant lines covered (99.91%)

1.0 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.1
/src/zope/i18n/format.py
1
##############################################################################
2
#
3
# Copyright (c) 2002, 2003 Zope Foundation and Contributors.
4
# All Rights Reserved.
5
#
6
# This software is subject to the provisions of the Zope Public License,
7
# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
8
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
9
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
10
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
11
# FOR A PARTICULAR PURPOSE.
12
#
13
##############################################################################
14
"""Basic Object Formatting
15

16
This module implements basic object formatting functionality, such as
17
date/time, number and money formatting.
18
"""
19
import datetime
1✔
20
import math
1✔
21
import re
1✔
22
import sys
1✔
23

24
import pytz
1✔
25
import pytz.reference
1✔
26

27
from zope.interface import implementer
1✔
28

29
from zope.i18n.interfaces import IDateTimeFormat
1✔
30
from zope.i18n.interfaces import INumberFormat
1✔
31

32

33
def roundHalfUp(n):
1✔
34
    """Works like round() in python2.x
35

36
    Implementation of round() was changed in python3 - it rounds halfs to
37
    nearest even number, so that round(0.5) == 0. This function is here to
38
    unify behaviour between python 2.x and 3.x for the purposes of this module.
39
    """
40
    return math.floor(n + math.copysign(0.5, n))
1✔
41

42

43
def _findFormattingCharacterInPattern(char, pattern):
1✔
44
    return [entry for entry in pattern
1✔
45
            if isinstance(entry, tuple) and entry[0] == char]
46

47

48
class DateTimeParseError(Exception):
1✔
49
    """Error is raised when parsing of datetime failed."""
50

51

52
@implementer(IDateTimeFormat)
1✔
53
class DateTimeFormat:
1✔
54
    __doc__ = IDateTimeFormat.__doc__
1✔
55

56
    _DATETIMECHARS = "aGyMdEDFwWhHmsSkKz"
1✔
57

58
    calendar = None
1✔
59
    _pattern = None
1✔
60
    _bin_pattern = None
1✔
61

62
    def __init__(self, pattern=None, calendar=None):
1✔
63
        if calendar is not None:
1!
64
            self.calendar = calendar
1✔
65
        self._pattern = pattern
1✔
66
        self._bin_pattern = None
1✔
67
        if pattern is not None:
1✔
68
            self.setPattern(pattern)
1✔
69

70
    def setPattern(self, pattern):
1✔
71
        "See zope.i18n.interfaces.IFormat"
72
        self._pattern = pattern
1✔
73
        self._bin_pattern = parseDateTimePattern(self._pattern,
1✔
74
                                                 self._DATETIMECHARS)
75

76
    def getPattern(self):
1✔
77
        "See zope.i18n.interfaces.IFormat"
78
        return self._pattern
1✔
79

80
    def parse(self, text, pattern=None, asObject=True):
1✔
81
        "See zope.i18n.interfaces.IFormat"
82
        # Make or get binary form of datetime pattern
83
        if pattern is not None:
1✔
84
            bin_pattern = parseDateTimePattern(pattern)
1✔
85
        else:
86
            bin_pattern = self._bin_pattern
1✔
87
            pattern = self._pattern
1✔
88

89
        # Generate the correct regular expression to parse the date and parse.
90
        regex = '^'
1✔
91
        info = buildDateTimeParseInfo(self.calendar, bin_pattern)
1✔
92
        for elem in bin_pattern:
1✔
93
            regex += info.get(elem, elem)
1✔
94
        regex += '$'
1✔
95
        try:
1✔
96
            results = re.match(regex, text).groups()
1✔
97
        except AttributeError:
1✔
98
            raise DateTimeParseError(
1✔
99
                'The datetime string did not match the pattern %r.'
100
                % pattern)
101
        # Sometimes you only want the parse results
102
        if not asObject:
1✔
103
            return results
1✔
104

105
        # Map the parsing results to a datetime object
106
        ordered = [None, None, None, None, None, None, None]
1✔
107
        bin_pattern = [x for x in bin_pattern if isinstance(x, tuple)]
1✔
108

109
        # Handle years; note that only 'yy' and 'yyyy' are allowed
110
        if ('y', 2) in bin_pattern:
1✔
111
            year = int(results[bin_pattern.index(('y', 2))])
1✔
112
            if year > 30:
1✔
113
                ordered[0] = 1900 + year
1✔
114
            else:
115
                ordered[0] = 2000 + year
1✔
116
        if ('y', 4) in bin_pattern:
1✔
117
            ordered[0] = int(results[bin_pattern.index(('y', 4))])
1✔
118

119
        # Handle months (text)
120
        month_entry = _findFormattingCharacterInPattern('M', bin_pattern)
1✔
121
        if month_entry and month_entry[0][1] == 3:
1✔
122
            abbr = results[bin_pattern.index(month_entry[0])]
1✔
123
            ordered[1] = self.calendar.getMonthTypeFromAbbreviation(abbr)
1✔
124
        elif month_entry and month_entry[0][1] >= 4:
1✔
125
            name = results[bin_pattern.index(month_entry[0])]
1✔
126
            ordered[1] = self.calendar.getMonthTypeFromName(name)
1✔
127
        elif month_entry and month_entry[0][1] <= 2:
1✔
128
            ordered[1] = int(results[bin_pattern.index(month_entry[0])])
1✔
129

130
        # Handle hours with AM/PM
131
        hour_entry = _findFormattingCharacterInPattern('h', bin_pattern)
1✔
132
        if hour_entry:
1✔
133
            hour = int(results[bin_pattern.index(hour_entry[0])])
1✔
134
            ampm_entry = _findFormattingCharacterInPattern('a', bin_pattern)
1✔
135
            if not ampm_entry:
1✔
136
                raise DateTimeParseError(
1✔
137
                    'Cannot handle 12-hour format without am/pm marker.')
138
            ampm = self.calendar.pm == results[bin_pattern.index(
1✔
139
                ampm_entry[0])]
140
            if hour == 12:
1✔
141
                ampm = not ampm
1✔
142
            ordered[3] = (hour + 12 * ampm) % 24
1✔
143

144
        # Shortcut for the simple int functions
145
        dt_fields_map = {'d': 2, 'H': 3, 'm': 4, 's': 5, 'S': 6}
1✔
146
        for field in dt_fields_map:
1✔
147
            entry = _findFormattingCharacterInPattern(field, bin_pattern)
1✔
148
            if not entry:
1✔
149
                continue
1✔
150
            pos = dt_fields_map[field]
1✔
151
            ordered[pos] = int(results[bin_pattern.index(entry[0])])
1✔
152

153
        # Handle timezones
154
        tzinfo = None
1✔
155
        pytz_tzinfo = False  # If True, we should use pytz specific syntax
1✔
156
        tz_entry = _findFormattingCharacterInPattern('z', bin_pattern)
1✔
157
        if ordered[3:] != [None, None, None, None] and tz_entry:
1✔
158
            length = tz_entry[0][1]
1✔
159
            value = results[bin_pattern.index(tz_entry[0])]
1✔
160
            if length == 1:
1✔
161
                hours, mins = int(value[:-2]), int(value[-2:])
1✔
162
                tzinfo = pytz.FixedOffset(hours * 60 + mins)
1✔
163
            elif length == 2:
1✔
164
                hours, mins = int(value[:-3]), int(value[-2:])
1✔
165
                tzinfo = pytz.FixedOffset(hours * 60 + mins)
1✔
166
            else:
167
                try:
1✔
168
                    tzinfo = pytz.timezone(value)
1✔
169
                    pytz_tzinfo = True
1✔
170
                except KeyError:
1✔
171
                    # TODO: Find timezones using locale information
172
                    pass
1✔
173

174
        # Create a date/time object from the data
175
        # If we have a pytz tzinfo, we need to invoke localize() as per
176
        # the pytz documentation on creating local times.
177
        # NB. If we are in an end-of-DST transition period, we have a 50%
178
        # chance of getting a time 1 hour out here, but that is the price
179
        # paid for dealing with localtimes.
180
        if ordered[3:] == [None, None, None, None]:
1✔
181
            return datetime.date(*[e or 0 for e in ordered[:3]])
1✔
182
        if ordered[:3] == [None, None, None]:
1✔
183
            if pytz_tzinfo:
1✔
184
                return tzinfo.localize(
1✔
185
                    datetime.datetime.combine(
186
                        datetime.date.today(),
187
                        datetime.time(*[e or 0 for e in ordered[3:]]))
188
                    ).timetz()
189
            return datetime.time(
1✔
190
                *[e or 0 for e in ordered[3:]], **{'tzinfo': tzinfo}
191
            )
192

193
        if pytz_tzinfo:
1✔
194
            return tzinfo.localize(datetime.datetime(
1✔
195
                *[e or 0 for e in ordered]
196
            ))
197

198
        return datetime.datetime(
1✔
199
            *[e or 0 for e in ordered], **{'tzinfo': tzinfo}
200
        )
201

202
    def format(self, obj, pattern=None):
1✔
203
        "See zope.i18n.interfaces.IFormat"
204
        # Make or get binary form of datetime pattern
205
        if pattern is not None:
1✔
206
            bin_pattern = parseDateTimePattern(pattern)
1✔
207
        else:
208
            bin_pattern = self._bin_pattern
1✔
209

210
        text = ""
1✔
211
        info = buildDateTimeInfo(obj, self.calendar, bin_pattern)
1✔
212
        for elem in bin_pattern:
1✔
213
            text += info.get(elem, elem)
1✔
214

215
        return text
1✔
216

217

218
class NumberParseError(Exception):
1✔
219
    """Error that can be raised when smething unexpected happens during the
220
    number parsing process."""
221

222

223
@implementer(INumberFormat)
1✔
224
class NumberFormat:
1✔
225
    __doc__ = INumberFormat.__doc__
1✔
226

227
    type = None
1✔
228
    _pattern = None
1✔
229
    _bin_pattern = None
1✔
230

231
    def __init__(self, pattern=None, symbols=()):
1✔
232
        # setup default symbols
233
        self.symbols = {
1✔
234
            "decimal": ".",
235
            "group": ",",
236
            "list": ";",
237
            "percentSign": "%",
238
            "nativeZeroDigit": "0",
239
            "patternDigit": "#",
240
            "plusSign": "+",
241
            "minusSign": "-",
242
            "exponential": "E",
243
            "perMille": "\xe2\x88\x9e",
244
            "infinity": "\xef\xbf\xbd",
245
            "nan": ''
246
        }
247
        self.symbols.update(symbols)
1✔
248
        if pattern is not None:
1✔
249
            self.setPattern(pattern)
1✔
250

251
    def setPattern(self, pattern):
1✔
252
        "See zope.i18n.interfaces.IFormat"
253
        self._pattern = pattern
1✔
254
        self._bin_pattern = parseNumberPattern(self._pattern)
1✔
255

256
    def getPattern(self):
1✔
257
        "See zope.i18n.interfaces.IFormat"
258
        return self._pattern
1✔
259

260
    def parse(self, text, pattern=None):
1✔
261
        "See zope.i18n.interfaces.IFormat"
262
        # Make or get binary form of datetime pattern
263
        if pattern is not None:
1✔
264
            bin_pattern = parseNumberPattern(pattern)
1✔
265
        else:
266
            bin_pattern = self._bin_pattern
1✔
267
            pattern = self._pattern
1✔
268
        # Determine sign
269
        num_res = [None, None]
1✔
270
        for sign in (0, 1):
1✔
271
            regex = '^'
1✔
272
            if bin_pattern[sign][PADDING1] is not None:
1✔
273
                regex += '[' + bin_pattern[sign][PADDING1] + ']+'
1✔
274
            if bin_pattern[sign][PREFIX] != '':
1✔
275
                regex += '[' + bin_pattern[sign][PREFIX] + ']'
1✔
276
            if bin_pattern[sign][PADDING2] is not None:
1✔
277
                regex += '[' + bin_pattern[sign][PADDING2] + ']+'
1✔
278
            regex += '([0-9'
1✔
279
            min_size = bin_pattern[sign][INTEGER].count('0')
1✔
280
            if bin_pattern[sign][GROUPING]:
1✔
281
                regex += self.symbols['group']
1✔
282
                min_size += min_size / 3
1✔
283
            regex += ']{%i,100}' % (min_size)
1✔
284
            if bin_pattern[sign][FRACTION]:
1✔
285
                max_precision = len(bin_pattern[sign][FRACTION])
1✔
286
                min_precision = bin_pattern[sign][FRACTION].count('0')
1✔
287
                regex += '[' + self.symbols['decimal'] + ']?'
1✔
288
                regex += '[0-9]{%i,%i}' % (min_precision, max_precision)
1✔
289
            if bin_pattern[sign][EXPONENTIAL] != '':
1✔
290
                regex += self.symbols['exponential']
1✔
291
                min_exp_size = bin_pattern[sign][EXPONENTIAL].count('0')
1✔
292
                pre_symbols = self.symbols['minusSign']
1✔
293
                if bin_pattern[sign][EXPONENTIAL][0] == '+':
1✔
294
                    pre_symbols += self.symbols['plusSign']
1✔
295
                regex += '[%s]?[0-9]{%i,100}' % (pre_symbols, min_exp_size)
1✔
296
            regex += ')'
1✔
297
            if bin_pattern[sign][PADDING3] is not None:
1✔
298
                regex += '[' + bin_pattern[sign][PADDING3] + ']+'
1✔
299
            if bin_pattern[sign][SUFFIX] != '':
1✔
300
                regex += '[' + bin_pattern[sign][SUFFIX] + ']'
1✔
301
            if bin_pattern[sign][PADDING4] is not None:
1✔
302
                regex += '[' + bin_pattern[sign][PADDING4] + ']+'
1✔
303
            regex += '$'
1✔
304
            num_res[sign] = re.match(regex, text)
1✔
305

306
        if num_res[0] is not None:
1✔
307
            num_str = num_res[0].groups()[0]
1✔
308
            sign = +1
1✔
309
        elif num_res[1] is not None:
1✔
310
            num_str = num_res[1].groups()[0]
1✔
311
            sign = -1
1✔
312
        else:
313
            raise NumberParseError('Not a valid number for this pattern %r.'
1✔
314
                                   % pattern)
315
        # Remove possible grouping separators
316
        num_str = num_str.replace(self.symbols['group'], '')
1✔
317
        # Extract number
318
        type = int
1✔
319
        if self.symbols['decimal'] in num_str:
1✔
320
            type = float
1✔
321
            num_str = num_str.replace(self.symbols['decimal'], '.')
1✔
322
        if self.symbols['exponential'] in num_str:
1✔
323
            type = float
1✔
324
            num_str = num_str.replace(self.symbols['exponential'], 'E')
1✔
325
        if self.type:
1✔
326
            type = self.type
1✔
327
        return sign * type(num_str)
1✔
328

329
    def _format_integer(self, integer, pattern):
1✔
330
        size = len(integer)
1✔
331
        min_size = pattern.count('0')
1✔
332
        if size < min_size:
1✔
333
            integer = self.symbols['nativeZeroDigit'] * \
1✔
334
                (min_size - size) + integer
335
        return integer
1✔
336

337
    def _format_fraction(self, fraction, pattern, rounding=True):
1✔
338
        if rounding:
1✔
339
            max_precision = len(pattern)
1✔
340
        else:
341
            max_precision = sys.maxsize
1✔
342
        min_precision = pattern.count('0')
1✔
343
        precision = len(fraction)
1✔
344
        roundInt = False
1✔
345
        if precision > max_precision:
1✔
346
            round = int(fraction[max_precision]) >= 5
1✔
347
            fraction = fraction[:max_precision]
1✔
348
            if round:
1✔
349
                if fraction != '':
1✔
350
                    # add 1 to the fraction, maintaining the decimal
351
                    # precision; if the result >= 1, need to roundInt
352
                    fractionLen = len(fraction)
1✔
353
                    rounded = int(fraction) + 1
1✔
354
                    fraction = ('%0' + str(fractionLen) + 'i') % rounded
1✔
355
                    if len(fraction) > fractionLen:  # rounded fraction >= 1
1✔
356
                        roundInt = True
1✔
357
                        fraction = fraction[1:]
1✔
358
                else:
359
                    # fraction missing, e.g. 1.5 -> 1. -- need to roundInt
360
                    roundInt = True
1✔
361

362
        if precision < min_precision:
1✔
363
            fraction += self.symbols['nativeZeroDigit'] * (min_precision -
1✔
364
                                                           precision)
365
        if fraction != '':
1✔
366
            fraction = self.symbols['decimal'] + fraction
1✔
367
        return fraction, roundInt
1✔
368

369
    # taken from cpython lib/Locale.py
370
    def _grouping_intervals(self, grouping):
1✔
371
        last_interval = None
1✔
372
        for interval in grouping:
1!
373
            # 0: re-use last group ad infinitum
374
            if interval == 0:
1✔
375
                if last_interval is None:
1✔
376
                    raise ValueError("invalid grouping")
1✔
377
                while True:
1✔
378
                    yield last_interval
1✔
379
            yield interval
1✔
380
            last_interval = interval
1✔
381

382
    def _group(self, integer, grouping):
1✔
383
        # take a given chunk of digits and insert the group symbol
384
        # grouping is usually: (3, 0) or (3, 2, 0)
385
        digits = list(reversed(integer))
1✔
386
        last_idx = 0
1✔
387
        for group_length in self._grouping_intervals(grouping):
1!
388
            pos = last_idx + group_length
1✔
389
            if pos >= len(digits):
1✔
390
                break
1✔
391
            digits.insert(pos, self.symbols['group'])
1✔
392
            last_idx = pos + 1
1✔
393
        res = ''.join(reversed(digits))
1✔
394
        return res
1✔
395

396
    def format(self, obj, pattern=None, rounding=True):
1✔
397
        "See zope.i18n.interfaces.IFormat"
398
        # Make or get binary form of datetime pattern
399
        if pattern is not None:
1✔
400
            bin_pattern = parseNumberPattern(pattern)
1✔
401
        else:
402
            bin_pattern = self._bin_pattern
1✔
403
        # Get positive or negative sub-pattern
404
        if obj >= 0:
1✔
405
            bin_pattern = bin_pattern[0]
1✔
406
        else:
407
            bin_pattern = bin_pattern[1]
1✔
408

409
        strobj = str(obj)
1✔
410
        if 'e' in strobj:
1✔
411
            # Str(obj) # returned scientific representation of a number (e.g.
412
            # 1e-7). We can't rely on str() to format fraction.
413
            decimalprec = len(bin_pattern[FRACTION]) or 1
1✔
414
            obj_int, obj_frac = ("%.*f" % (decimalprec, obj)).split('.')
1✔
415
            # Remove trailing 0, but leave at least one
416
            obj_frac = obj_frac.rstrip("0") or "0"
1✔
417
            obj_int_frac = [obj_int, obj_frac]
1✔
418
        else:
419
            obj_int_frac = strobj.split('.')
1✔
420

421
        if bin_pattern[EXPONENTIAL] != '':
1✔
422
            # The exponential might have a mandatory sign; remove it from the
423
            # bin_pattern and remember the setting
424
            exp_bin_pattern = bin_pattern[EXPONENTIAL]
1✔
425
            plus_sign = ""
1✔
426
            if exp_bin_pattern.startswith('+'):
1✔
427
                plus_sign = self.symbols['plusSign']
1✔
428
                exp_bin_pattern = exp_bin_pattern[1:]
1✔
429
            # We have to remove the possible '-' sign
430
            if obj < 0:
1✔
431
                obj_int_frac[0] = obj_int_frac[0][1:]
1✔
432
            if obj_int_frac[0] == '0':
1✔
433
                # abs() of number smaller 1
434
                if len(obj_int_frac) > 1:
1✔
435
                    res = re.match('(0*)[0-9]*', obj_int_frac[1]).groups()[0]
1✔
436
                    exponent = self._format_integer(str(len(res) + 1),
1✔
437
                                                    exp_bin_pattern)
438
                    exponent = self.symbols['minusSign'] + exponent
1✔
439
                    number = obj_int_frac[1][len(res):]
1✔
440
                else:
441
                    # We have exactly 0
442
                    exponent = self._format_integer('0', exp_bin_pattern)
1✔
443
                    number = self.symbols['nativeZeroDigit']
1✔
444
            else:
445
                exponent = self._format_integer(str(len(obj_int_frac[0]) - 1),
1✔
446
                                                exp_bin_pattern)
447
                number = ''.join(obj_int_frac)
1✔
448

449
            fraction, roundInt = self._format_fraction(number[1:],
1✔
450
                                                       bin_pattern[FRACTION],
451
                                                       rounding=rounding)
452
            if roundInt:
1✔
453
                number = str(int(number[0]) + 1) + fraction
1✔
454
            else:
455
                number = number[0] + fraction
1✔
456

457
            # We might have a plus sign in front of the exponential integer
458
            if not exponent.startswith('-'):
1✔
459
                exponent = plus_sign + exponent
1✔
460

461
            pre_padding = len(bin_pattern[FRACTION]) - len(number) + 2
1✔
462
            post_padding = len(exp_bin_pattern) - len(exponent)
1✔
463
            number += self.symbols['exponential'] + exponent
1✔
464

465
        else:
466
            if len(obj_int_frac) > 1:
1✔
467
                fraction, roundInt = self._format_fraction(
1✔
468
                    obj_int_frac[1], bin_pattern[FRACTION], rounding=rounding)
469
            else:
470
                fraction = ''
1✔
471
                roundInt = False
1✔
472
            if roundInt:
1✔
473
                obj = roundHalfUp(obj)
1✔
474
            integer = self._format_integer(str(int(math.fabs(obj))),
1✔
475
                                           bin_pattern[INTEGER])
476
            # Adding grouping
477
            if bin_pattern[GROUPING]:
1✔
478
                integer = self._group(integer, bin_pattern[GROUPING])
1✔
479
            pre_padding = len(bin_pattern[INTEGER]) - len(integer)
1✔
480
            post_padding = len(bin_pattern[FRACTION]) - len(fraction) + 1
1✔
481
            number = integer + fraction
1✔
482

483
        # Put it all together
484
        text = ''
1✔
485
        if bin_pattern[PADDING1] is not None and pre_padding > 0:
1✔
486
            text += bin_pattern[PADDING1] * pre_padding
1✔
487
        text += bin_pattern[PREFIX]
1✔
488
        if bin_pattern[PADDING2] is not None and pre_padding > 0:
1✔
489
            if bin_pattern[PADDING1] is not None:
1✔
490
                text += bin_pattern[PADDING2]
1✔
491
            else:  # pragma: no cover
492
                text += bin_pattern[PADDING2] * pre_padding
493
        text += number
1✔
494
        if bin_pattern[PADDING3] is not None and post_padding > 0:
1✔
495
            if bin_pattern[PADDING4] is not None:
1✔
496
                text += bin_pattern[PADDING3]
1✔
497
            else:
498
                text += bin_pattern[PADDING3] * post_padding
1✔
499
        text += bin_pattern[SUFFIX]
1✔
500
        if bin_pattern[PADDING4] is not None and post_padding > 0:
1✔
501
            text += bin_pattern[PADDING4] * post_padding
1✔
502

503
        # TODO: Need to make sure str is everywhere
504
        return str(text)
1✔
505

506

507
DEFAULT = 0
1✔
508
IN_QUOTE = 1
1✔
509
IN_DATETIMEFIELD = 2
1✔
510

511

512
class DateTimePatternParseError(Exception):
1✔
513
    """DateTime Pattern Parse Error"""
514

515

516
def parseDateTimePattern(pattern, DATETIMECHARS="aGyMdEDFwWhHmsSkKz"):
1✔
517
    """This method can handle everything: time, date and datetime strings."""
518
    result = []
1✔
519
    state = DEFAULT
1✔
520
    helper = ''
1✔
521
    char = ''
1✔
522
    quote_start = -2
1✔
523

524
    for pos, next_char in enumerate(pattern):
1✔
525
        prev_char = char
1✔
526
        char = next_char
1✔
527
        # Handle quotations
528
        if char == "'":
1✔
529
            if state == DEFAULT:
1✔
530
                quote_start = pos
1✔
531
                state = IN_QUOTE
1✔
532
            elif state == IN_QUOTE and prev_char == "'":
1✔
533
                helper += char
1✔
534
                state = DEFAULT
1✔
535
            elif state == IN_QUOTE:
1✔
536
                # Do not care about putting the content of the quote in the
537
                # result. The next state is responsible for that.
538
                quote_start = -1
1✔
539
                state = DEFAULT
1✔
540
            elif state == IN_DATETIMEFIELD:
1!
541
                result.append((helper[0], len(helper)))
1✔
542
                helper = ''
1✔
543
                quote_start = pos
1✔
544
                state = IN_QUOTE
1✔
545
        elif state == IN_QUOTE:
1✔
546
            helper += char
1✔
547

548
        # Handle regular characters
549
        elif char not in DATETIMECHARS:
1✔
550
            if state == IN_DATETIMEFIELD:
1✔
551
                result.append((helper[0], len(helper)))
1✔
552
                helper = char
1✔
553
                state = DEFAULT
1✔
554
            elif state == DEFAULT:
1!
555
                helper += char
1✔
556

557
        # Handle special formatting characters
558
        elif char in DATETIMECHARS:
1!
559
            if state == DEFAULT:
1✔
560
                # Clean up helper first
561
                if helper:
1✔
562
                    result.append(helper)
1✔
563
                helper = char
1✔
564
                state = IN_DATETIMEFIELD
1✔
565

566
            elif state == IN_DATETIMEFIELD and prev_char == char:
1✔
567
                helper += char
1✔
568

569
            elif state == IN_DATETIMEFIELD and prev_char != char:
1!
570
                result.append((helper[0], len(helper)))
1✔
571
                helper = char
1✔
572

573
    # Some cleaning up
574
    if state == IN_QUOTE:
1✔
575
        if quote_start == -1:  # pragma: no cover
576
            # It should not be possible to get into this state.
577
            # The only time we set quote_start to -1 we also set the state
578
            # to DEFAULT.
579
            raise DateTimePatternParseError(
580
                'Waaa: state = IN_QUOTE and quote_start = -1!')
581
        else:
582
            raise DateTimePatternParseError(
1✔
583
                'The quote starting at character %i is not closed.' %
584
                quote_start)
585
    elif state == IN_DATETIMEFIELD:
1✔
586
        result.append((helper[0], len(helper)))
1✔
587
    elif state == DEFAULT:
1!
588
        result.append(helper)
1✔
589

590
    return result
1✔
591

592

593
def buildDateTimeParseInfo(calendar, pattern):
1✔
594
    """This method returns a dictionary that helps us with the parsing.
595
    It also depends on the locale of course."""
596
    info = {}
1✔
597
    # Generic Numbers
598
    for field in 'dDFkKhHmsSwW':
1✔
599
        for entry in _findFormattingCharacterInPattern(field, pattern):
1✔
600
            # The maximum amount of digits should be infinity, but 1000 is
601
            # close enough here.
602
            info[entry] = r'([0-9]{%i,1000})' % entry[1]
1✔
603

604
    # year (Number)
605
    for entry in _findFormattingCharacterInPattern('y', pattern):
1✔
606
        if entry[1] == 2:
1✔
607
            info[entry] = r'([0-9]{2})'
1✔
608
        elif entry[1] == 4:
1✔
609
            info[entry] = r'([0-9]{4})'
1✔
610
        else:
611
            raise DateTimePatternParseError("Only 'yy' and 'yyyy' allowed.")
1✔
612

613
    # am/pm marker (Text)
614
    for entry in _findFormattingCharacterInPattern('a', pattern):
1✔
615
        info[entry] = fr'({calendar.am}|{calendar.pm})'
1✔
616

617
    # era designator (Text)
618
    # TODO: works for gregorian only right now
619
    for entry in _findFormattingCharacterInPattern('G', pattern):
1✔
620
        info[entry] = r'({}|{})'.format(
1✔
621
            calendar.eras[1][1], calendar.eras[2][1])
622

623
    # time zone (Text)
624
    for entry in _findFormattingCharacterInPattern('z', pattern):
1✔
625
        if entry[1] == 1:
1✔
626
            info[entry] = r'([\+-][0-9]{3,4})'
1✔
627
        elif entry[1] == 2:
1✔
628
            info[entry] = r'([\+-][0-9]{2}:[0-9]{2})'
1✔
629
        elif entry[1] == 3:
1✔
630
            info[entry] = r'([a-zA-Z]{3})'
1✔
631
        else:
632
            info[entry] = r'([a-zA-Z /\.]*)'
1✔
633

634
    # month in year (Text and Number)
635
    for entry in _findFormattingCharacterInPattern('M', pattern):
1✔
636
        if entry[1] == 1:
1✔
637
            info[entry] = r'([0-9]{1,2})'
1✔
638
        elif entry[1] == 2:
1✔
639
            info[entry] = r'([0-9]{2})'
1✔
640
        elif entry[1] == 3:
1✔
641
            info[entry] = r'(' + \
1✔
642
                '|'.join(calendar.getMonthAbbreviations()) + ')'
643
        else:
644
            info[entry] = r'(' + '|'.join(calendar.getMonthNames()) + ')'
1✔
645

646
    # day in week (Text and Number)
647
    for entry in _findFormattingCharacterInPattern('E', pattern):
1✔
648
        if entry[1] == 1:
1✔
649
            info[entry] = r'([0-9])'
1✔
650
        elif entry[1] == 2:
1✔
651
            info[entry] = r'([0-9]{2})'
1✔
652
        elif entry[1] == 3:
1✔
653
            info[entry] = r'(' + '|'.join(calendar.getDayAbbreviations()) + ')'
1✔
654
        else:
655
            info[entry] = r'(' + '|'.join(calendar.getDayNames()) + ')'
1✔
656

657
    return info
1✔
658

659

660
def buildDateTimeInfo(dt, calendar, pattern):
1✔
661
    """Create the bits and pieces of the datetime object that can be put
662
    together."""
663
    if isinstance(dt, datetime.time):
1✔
664
        dt = datetime.datetime(1969, 1, 1, dt.hour, dt.minute, dt.second,
1✔
665
                               dt.microsecond)
666
    elif (isinstance(dt, datetime.date) and
1✔
667
          not isinstance(dt, datetime.datetime)):
668
        dt = datetime.datetime(dt.year, dt.month, dt.day)
1✔
669

670
    if dt.hour >= 12:
1✔
671
        ampm = calendar.pm
1✔
672
    else:
673
        ampm = calendar.am
1✔
674

675
    h = dt.hour % 12
1✔
676
    if h == 0:
1✔
677
        h = 12
1✔
678

679
    weekday = (dt.weekday() + (8 - calendar.week['firstDay'])) % 7 + 1
1✔
680

681
    day_of_week_in_month = (dt.day - 1) / 7 + 1
1✔
682

683
    week_in_month = (dt.day + 6 - dt.weekday()) / 7 + 1
1✔
684

685
    # Getting the timezone right
686
    tzinfo = dt.tzinfo or pytz.utc
1✔
687
    tz_secs = tzinfo.utcoffset(dt).seconds
1✔
688
    tz_secs = tz_secs - 24 * 3600 if tz_secs > 12 * 3600 else tz_secs
1✔
689
    tz_mins = int(math.fabs(tz_secs % 3600 / 60))
1✔
690
    tz_hours = int(math.fabs(tz_secs / 3600))
1✔
691
    tz_sign = '-' if tz_secs < 0 else '+'
1✔
692
    tz_defaultname = "%s%i%.2i" % (tz_sign, tz_hours, tz_mins)
1✔
693
    tz_name = tzinfo.tzname(dt) or tz_defaultname
1✔
694
    tz_fullname = getattr(tzinfo, 'zone', None) or tz_name
1✔
695

696
    info = {
1✔
697
        ('y', 2): str(dt.year)[2:],
698
        ('y', 4): str(dt.year),
699
    }
700

701
    # Generic Numbers
702
    for field, value in (('d', dt.day), ('D', int(dt.strftime('%j'))),
1✔
703
                         ('F', day_of_week_in_month), ('k', dt.hour or 24),
704
                         ('K', dt.hour % 12), ('h', h), ('H', dt.hour),
705
                         ('m', dt.minute), ('s', dt.second),
706
                         ('S', dt.microsecond), ('w', int(dt.strftime('%W'))),
707
                         ('W', week_in_month)):
708
        for entry in _findFormattingCharacterInPattern(field, pattern):
1✔
709
            info[entry] = ("%%.%ii" % entry[1]) % value
1✔
710

711
    # am/pm marker (Text)
712
    for entry in _findFormattingCharacterInPattern('a', pattern):
1✔
713
        info[entry] = ampm
1✔
714

715
    # era designator (Text)
716
    # TODO: works for gregorian only right now
717
    for entry in _findFormattingCharacterInPattern('G', pattern):
1✔
718
        info[entry] = calendar.eras[2][1]
1✔
719

720
    # time zone (Text)
721
    for entry in _findFormattingCharacterInPattern('z', pattern):
1✔
722
        if entry[1] == 1:
1✔
723
            info[entry] = "%s%i%.2i" % (tz_sign, tz_hours, tz_mins)
1✔
724
        elif entry[1] == 2:
1✔
725
            info[entry] = "%s%.2i:%.2i" % (tz_sign, tz_hours, tz_mins)
1✔
726
        elif entry[1] == 3:
1✔
727
            info[entry] = tz_name
1✔
728
        else:
729
            info[entry] = tz_fullname
1✔
730

731
    # month in year (Text and Number)
732
    for entry in _findFormattingCharacterInPattern('M', pattern):
1✔
733
        if entry[1] == 1:
1✔
734
            info[entry] = "%i" % dt.month
1✔
735
        elif entry[1] == 2:
1✔
736
            info[entry] = "%.2i" % dt.month
1✔
737
        elif entry[1] == 3:
1✔
738
            info[entry] = calendar.months[dt.month][1]
1✔
739
        else:
740
            info[entry] = calendar.months[dt.month][0]
1✔
741

742
    # day in week (Text and Number)
743
    for entry in _findFormattingCharacterInPattern('E', pattern):
1✔
744
        if entry[1] == 1:
1✔
745
            info[entry] = "%i" % weekday
1✔
746
        elif entry[1] == 2:
1✔
747
            info[entry] = "%.2i" % weekday
1✔
748
        elif entry[1] == 3:
1✔
749
            info[entry] = calendar.days[dt.weekday() + 1][1]
1✔
750
        else:
751
            info[entry] = calendar.days[dt.weekday() + 1][0]
1✔
752

753
    return info
1✔
754

755

756
# Number Pattern Parser States
757
BEGIN = 0
1✔
758
READ_PADDING_1 = 1
1✔
759
READ_PREFIX = 2
1✔
760
READ_PREFIX_STRING = 3
1✔
761
READ_PADDING_2 = 4
1✔
762
READ_INTEGER = 5
1✔
763
READ_FRACTION = 6
1✔
764
READ_EXPONENTIAL = 7
1✔
765
READ_PADDING_3 = 8
1✔
766
READ_SUFFIX = 9
1✔
767
READ_SUFFIX_STRING = 10
1✔
768
READ_PADDING_4 = 11
1✔
769
READ_NEG_SUBPATTERN = 12
1✔
770

771
# Binary Pattern Locators
772
PADDING1 = 0
1✔
773
PREFIX = 1
1✔
774
PADDING2 = 2
1✔
775
INTEGER = 3
1✔
776
FRACTION = 4
1✔
777
EXPONENTIAL = 5
1✔
778
PADDING3 = 6
1✔
779
SUFFIX = 7
1✔
780
PADDING4 = 8
1✔
781
GROUPING = 9
1✔
782

783

784
class NumberPatternParseError(Exception):
1✔
785
    """Number Pattern Parse Error"""
786

787

788
def parseNumberPattern(pattern):
1✔
789
    """Parses all sorts of number pattern."""
790
    prefix = ''
1✔
791
    padding_1 = None
1✔
792
    padding_2 = None
1✔
793
    padding_3 = None
1✔
794
    padding_4 = None
1✔
795
    integer = ''
1✔
796
    fraction = ''
1✔
797
    exponential = ''
1✔
798
    suffix = ''
1✔
799
    neg_pattern = None
1✔
800

801
    SPECIALCHARS = "*.,#0;E'"
1✔
802

803
    state = BEGIN
1✔
804
    helper = ''
1✔
805
    for pos, char in enumerate(pattern):
1✔
806
        if state == BEGIN:
1✔
807
            if char == '*':
1✔
808
                state = READ_PADDING_1
1✔
809
            elif char not in SPECIALCHARS:
1✔
810
                state = READ_PREFIX
1✔
811
                prefix += char
1✔
812
            elif char == "'":
1✔
813
                state = READ_PREFIX_STRING
1✔
814
            elif char in '#0':
1✔
815
                state = READ_INTEGER
1✔
816
                helper += char
1✔
817
            else:
818
                raise NumberPatternParseError(
1✔
819
                    'Wrong syntax at beginning of pattern.')
820

821
        elif state == READ_PADDING_1:
1✔
822
            padding_1 = char
1✔
823
            state = READ_PREFIX
1✔
824

825
        elif state == READ_PREFIX:
1✔
826
            if char == "*":
1✔
827
                state = READ_PADDING_2
1✔
828
            elif char == "'":
1✔
829
                state = READ_PREFIX_STRING
1✔
830
            elif char == "#" or char == "0":
1✔
831
                state = READ_INTEGER
1✔
832
                helper += char
1✔
833
            else:
834
                prefix += char
1✔
835

836
        elif state == READ_PREFIX_STRING:
1✔
837
            if char == "'":
1✔
838
                state = READ_PREFIX
1✔
839
            else:
840
                prefix += char
1✔
841

842
        elif state == READ_PADDING_2:
1✔
843
            padding_2 = char
1✔
844
            state = READ_INTEGER
1✔
845

846
        elif state == READ_INTEGER:
1✔
847
            if char == "#" or char == "0":
1✔
848
                helper += char
1✔
849
            elif char == ",":
1✔
850
                # just add grouping markers to the integer pattern
851
                helper += char
1✔
852
            elif char == ".":
1✔
853
                integer = helper
1✔
854
                helper = ''
1✔
855
                state = READ_FRACTION
1✔
856
            elif char == "E":
1✔
857
                integer = helper
1✔
858
                helper = ''
1✔
859
                state = READ_EXPONENTIAL
1✔
860
            elif char == "*":
1✔
861
                integer = helper
1✔
862
                helper = ''
1✔
863
                state = READ_PADDING_3
1✔
864
            elif char == ";":
1✔
865
                integer = helper
1✔
866
                state = READ_NEG_SUBPATTERN
1✔
867
            elif char == "'":
1✔
868
                integer = helper
1✔
869
                state = READ_SUFFIX_STRING
1✔
870
            else:
871
                integer = helper
1✔
872
                suffix += char
1✔
873
                state = READ_SUFFIX
1✔
874

875
        elif state == READ_FRACTION:
1✔
876
            if char == "#" or char == "0":
1✔
877
                helper += char
1✔
878
            elif char == "E":
1✔
879
                fraction = helper
1✔
880
                helper = ''
1✔
881
                state = READ_EXPONENTIAL
1✔
882
            elif char == "*":
1✔
883
                fraction = helper
1✔
884
                helper = ''
1✔
885
                state = READ_PADDING_3
1✔
886
            elif char == ";":
1✔
887
                fraction = helper
1✔
888
                state = READ_NEG_SUBPATTERN
1✔
889
            elif char == "'":
1✔
890
                fraction = helper
1✔
891
                state = READ_SUFFIX_STRING
1✔
892
            else:
893
                fraction = helper
1✔
894
                suffix += char
1✔
895
                state = READ_SUFFIX
1✔
896

897
        elif state == READ_EXPONENTIAL:
1✔
898
            if char in ('0', '#', '+'):
1✔
899
                helper += char
1✔
900
            elif char == "*":
1✔
901
                exponential = helper
1✔
902
                helper = ''
1✔
903
                state = READ_PADDING_3
1✔
904
            elif char == ";":
1✔
905
                exponential = helper
1✔
906
                state = READ_NEG_SUBPATTERN
1✔
907
            elif char == "'":
1✔
908
                exponential = helper
1✔
909
                state = READ_SUFFIX_STRING
1✔
910
            else:
911
                exponential = helper
1✔
912
                suffix += char
1✔
913
                state = READ_SUFFIX
1✔
914

915
        elif state == READ_PADDING_3:
1✔
916
            padding_3 = char
1✔
917
            state = READ_SUFFIX
1✔
918

919
        elif state == READ_SUFFIX:
1✔
920
            if char == "*":
1✔
921
                state = READ_PADDING_4
1✔
922
            elif char == "'":
1✔
923
                state = READ_SUFFIX_STRING
1✔
924
            elif char == ";":
1✔
925
                state = READ_NEG_SUBPATTERN
1✔
926
            else:
927
                suffix += char
1✔
928

929
        elif state == READ_SUFFIX_STRING:
1✔
930
            if char == "'":
1✔
931
                state = READ_SUFFIX
1✔
932
            else:
933
                suffix += char
1✔
934

935
        elif state == READ_PADDING_4:
1✔
936
            if char == ';':
1✔
937
                state = READ_NEG_SUBPATTERN
1✔
938
            else:
939
                padding_4 = char
1✔
940

941
        elif state == READ_NEG_SUBPATTERN:
1!
942
            neg_pattern = parseNumberPattern(pattern[pos:])[0]
1✔
943
            break
1✔
944

945
    # Cleaning up states after end of parsing
946
    if state == READ_INTEGER:
1✔
947
        integer = helper
1✔
948
    if state == READ_FRACTION:
1✔
949
        fraction = helper
1✔
950
    if state == READ_EXPONENTIAL:
1✔
951
        exponential = helper
1✔
952

953
    # the integer pattern can have the grouping delimiters too, let's take care
954
    # about those here and now
955
    # convert to a tuple of length of groups, from right to left
956
    # example: (3, 0) for the usual triple separated, (3, 2, 0) for Hindi
957
    # practically trying to return the same as locale.localeconv()['grouping']
958
    grouping = ()
1✔
959
    if "," in integer:
1✔
960
        last_index = -1
1✔
961
        for index, char in enumerate(reversed(integer)):
1✔
962
            if char == ",":
1✔
963
                grouping += (index - last_index - 1,)
1✔
964
                last_index = index
1✔
965
        # use last group ad infinitum
966
        grouping += (0,)
1✔
967
        # remove grouping markers from integer pattern
968
        integer = integer.replace(",", "")
1✔
969

970
    pattern = (padding_1, prefix, padding_2, integer, fraction, exponential,
1✔
971
               padding_3, suffix, padding_4, grouping)
972

973
    if neg_pattern is None:
1✔
974
        neg_pattern = pattern
1✔
975

976
    return pattern, neg_pattern
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc