Repository: python-babel/babel
Pull request #349 (2016-02-15 21:21:44)

Day period API
This PR adds full support for day periods other than am/pm.

Requested by @sachinpali146 for additional time format character support. :)

See: http://unicode.org/reports/tr35/tr35-dates.html#Day_Period_Rules

codecov-io: ## [Current coverage][1] is `88.99%`
> Merging **#349** into **master** will increase coverage by **+0.01%** as of [`262bf92`][3]


```diff
@@            master    #349   diff @@
======================================
  Files           23      23       
  Stmts         3794    3826    +32
  Branches         0       0       
  Methods          0       0       
======================================
+ Hit           3376    3405    +29
  Partial          0       0       
- Missed         418     421     +3
```

> Review entire [Coverage Diff][4] as of [`262bf92`][3]


[1]: https://codecov.io/github/python-babel/babel?ref=262bf921cb471c43e0ccf91a53afddf52d3eb69e
[2]: https://codecov.io/github/python-babel/babel/features/suggestions?ref=262bf921cb471c43e0ccf91a53afddf52d3eb69e
[3]: https://codecov.io/github/python-babel/babel/commit/262bf921cb471c43e0ccf91a53afddf52d3eb69e
[4]: https://codecov.io/github/python-babel/babel/compare/f3f4b21ec3e95d05396681bd8d4ca21768e53e87...262bf921cb471c43e0ccf91a53afddf52d3eb69e

> Powered by [Codecov](https://codecov.io). Updated on successful CI builds.

Patch:
diff --git a/babel/core.py b/babel/core.py
index 8764360..c7d299e 100644
--- a/babel/core.py
+++ b/babel/core.py
@@ -614,7 +614,24 @@ class Locale(object):
         >>> Locale('en', 'US').periods['am']
         u'AM'
         """
-        return self._data['periods']
+        try:
+            return self._data['day_periods']['stand-alone']['wide']
+        except KeyError:
+            return {}
+
+    @property
+    def day_periods(self):
+        """Locale display names for various day periods (not necessarily only AM/PM).
+
+        These are not meant to be used without the relevant `day_period_rules`.
+        """
+        return self._data['day_periods']
+
+    @property
+    def day_period_rules(self):
+        """Day period rules for the locale.  Used by `get_period_id`.
+        """
+        return self._data.get('day_period_rules', {})
 
     @property
     def days(self):
diff --git a/babel/dates.py b/babel/dates.py
index 0ba2a2f..62ba79b 100644
--- a/babel/dates.py
+++ b/babel/dates.py
@@ -138,6 +138,32 @@ def _ensure_datetime_tzinfo(datetime, tzinfo=None):
     return datetime
 
 
+def _get_time(time, tzinfo=None):
+    """
+    Get a timezoned time from a given instant.
+
+    .. warning:: The return values of this function may depend on the system clock.
+
+    :param time: time, datetime or None
+    :rtype: time
+    """
+    if time is None:
+        time = datetime.utcnow()
+    elif isinstance(time, number_types):
+        time = datetime.utcfromtimestamp(time)
+    if time.tzinfo is None:
+        time = time.replace(tzinfo=UTC)
+    if isinstance(time, datetime):
+        if tzinfo is not None:
+            time = time.astimezone(tzinfo)
+            if hasattr(tzinfo, 'normalize'):  # pytz
+                time = tzinfo.normalize(time)
+        time = time.timetz()
+    elif tzinfo is not None:
+        time = time.replace(tzinfo=tzinfo)
+    return time
+
+
 def get_timezone(zone=None):
     """Looks up a timezone by name and returns it.  The timezone object
     returned comes from ``pytz`` and corresponds to the `tzinfo` interface and
@@ -247,15 +273,17 @@ class TimezoneTransition(object):
         )
 
 
-def get_period_names(locale=LC_TIME):
+def get_period_names(width='wide', context='stand-alone', locale=LC_TIME):
     """Return the names for day periods (AM/PM) used by the locale.
 
     >>> get_period_names(locale='en_US')['am']
     u'AM'
 
+    :param width: the width to use, one of "abbreviated", "narrow", or "wide"
+    :param context: the context, either "format" or "stand-alone"
     :param locale: the `Locale` object, or a locale string
     """
-    return Locale.parse(locale).periods
+    return Locale.parse(locale).day_periods[context][width]
 
 
 def get_day_names(width='wide', context='format', locale=LC_TIME):
@@ -751,20 +779,7 @@ def format_time(time=None, format='medium', tzinfo=None, locale=LC_TIME):
     :param tzinfo: the time-zone to apply to the time for display
     :param locale: a `Locale` object or a locale identifier
     """
-    if time is None:
-        time = datetime.utcnow()
-    elif isinstance(time, number_types):
-        time = datetime.utcfromtimestamp(time)
-    if time.tzinfo is None:
-        time = time.replace(tzinfo=UTC)
-    if isinstance(time, datetime):
-        if tzinfo is not None:
-            time = time.astimezone(tzinfo)
-            if hasattr(tzinfo, 'normalize'):  # pytz
-                time = tzinfo.normalize(time)
-        time = time.timetz()
-    elif tzinfo is not None:
-        time = time.replace(tzinfo=tzinfo)
+    time = _get_time(time, tzinfo)
 
     locale = Locale.parse(locale)
     if format in ('full', 'long', 'medium', 'short'):
@@ -1044,6 +1059,56 @@ def format_interval(start, end, skeleton=None, tzinfo=None, fuzzy=True, locale=L
     return _format_fallback_interval(start, end, skeleton, tzinfo, locale)
 
 
+def get_period_id(time, tzinfo=None, type=None, locale=LC_TIME):
+    """
+    Get the day period ID for a given time.
+
+    This ID can be used as a key for the period name dictionary.
+
+    >>> get_period_names(locale="de")[get_period_id(time(7, 42), locale="de")]
+    u'Morgen'
+
+    :param time: The time to inspect.
+    :param tzinfo: The timezone for the time. See ``format_time``.
+    :param type: The period type to use. Either "selection" or None.
+                 The selection type is used for selecting among phrases such as
+                 “Your email arrived yesterday evening” or “Your email arrived last night”.
+    :param locale: the `Locale` object, or a locale string
+    :return: period ID. Something is always returned -- even if it's just "am" or "pm".
+    """
+    time = _get_time(time, tzinfo)
+    seconds_past_midnight = int(time.hour * 60 * 60 + time.minute * 60 + time.second)
+    locale = Locale.parse(locale)
+
+    # The LDML rules state that the rules may not overlap, so iterating in arbitrary
+    # order should be alright.
+    for rule_id, rules in locale.day_period_rules.get(type, {}).items():
+        for rule in rules:
+            if "at" in rule and rule["at"] == seconds_past_midnight:
+                return rule_id
+
+            start_ok = end_ok = False
+
+            if "from" in rule and seconds_past_midnight >= rule["from"]:
+                start_ok = True
+            if "to" in rule and seconds_past_midnight <= rule["to"]:
+                # This rule type does not exist in the present CLDR data;
+                # excuse the lack of test coverage.
+                end_ok = True
+            if "before" in rule and seconds_past_midnight < rule["before"]:
+                end_ok = True
+            if "after" in rule and seconds_past_midnight > rule["after"]:
+                start_ok = True
+
+            if start_ok and end_ok:
+                return rule_id
+
+    if seconds_past_midnight < 43200:
+        return "am"
+    else:
+        return "pm"
+
+
 def parse_date(string, locale=LC_TIME):
     """Parse a date from a string.
 
diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py
index 5ffcbfc..00d11e9 100755
--- a/scripts/import_cldr.py
+++ b/scripts/import_cldr.py
@@ -132,6 +132,31 @@ def _extract_plural_rules(file_path):
     return rule_dict
 
 
+def _time_to_seconds_past_midnight(time_expr):
+    """
+    Parse a time expression to seconds after midnight.
+    :param time_expr: Time expression string (H:M or H:M:S)
+    :rtype: int
+    """
+    if time_expr is None:
+        return None
+    if time_expr.count(":") == 1:
+        time_expr += ":00"
+    hour, minute, second = [int(p, 10) for p in time_expr.split(":")]
+    return hour * 60 * 60 + minute * 60 + second
+
+
+def _compact_dict(dict):
+    """
+    "Compact" the given dict by removing items whose value is None or False.
+    """
+    out_dict = {}
+    for key, value in dict.items():
+        if value is not None and value is not False:
+            out_dict[key] = value
+    return out_dict
+
+
 def debug_repr(obj):
     if isinstance(obj, PluralRule):
         return obj.abstract
@@ -301,6 +326,7 @@ def parse_global(srcdir, sup):
 
 
 def _process_local_datas(sup, srcdir, destdir, force=False, dump_json=False):
+    day_period_rules = parse_day_period_rules(parse(os.path.join(srcdir, 'supplemental', 'dayPeriods.xml')))
     # build a territory containment mapping for inheritance
     regions = {}
     for elem in sup.findall('.//territoryContainment/group'):
@@ -355,15 +381,17 @@ def _process_local_datas(sup, srcdir, destdir, force=False, dump_json=False):
         log('Processing %s (Language = %s; Territory = %s)',
             filename, language, territory)
 
-        # plural rules
         locale_id = '_'.join(filter(None, [
             language,
             territory != '001' and territory or None
         ]))
+
         if locale_id in plural_rules:
             data['plural_form'] = plural_rules[locale_id]
         if locale_id in ordinal_rules:
             data['ordinal_form'] = ordinal_rules[locale_id]
+        if locale_id in day_period_rules:
+            data["day_period_rules"] = day_period_rules[locale_id]
 
         parse_locale_display_names(data, tree)
 
@@ -587,16 +615,17 @@ def parse_calendar_eras(data, calendar):
 
 
 def parse_calendar_periods(data, calendar):
-    # AM/PM
-    periods = data.setdefault('periods', {})
-    for day_period_width in calendar.findall(
-        'dayPeriods/dayPeriodContext/dayPeriodWidth'
-    ):
-        if day_period_width.attrib['type'] == 'wide':
+    # Day periods (AM/PM/others)
+    periods = data.setdefault('day_periods', {})
+    for day_period_ctx in calendar.findall('dayPeriods/dayPeriodContext'):
+        ctx_type = day_period_ctx.attrib["type"]
+        for day_period_width in day_period_ctx.findall('dayPeriodWidth'):
+            width_type = day_period_width.attrib["type"]
+            dest_dict = periods.setdefault(ctx_type, {}).setdefault(width_type, {})
             for day_period in day_period_width.findall('dayPeriod'):
+                period_type = day_period.attrib['type']
                 if 'alt' not in day_period.attrib:
-                    periods[day_period.attrib['type']] = text_type(
-                        day_period.text)
+                    dest_dict[period_type] = text_type(day_period.text)
 
 
 def parse_calendar_date_formats(data, calendar):
@@ -787,5 +816,29 @@ def parse_currency_formats(data, tree):
                     currency_formats[type] = numbers.parse_pattern(pattern)
 
 
+def parse_day_period_rules(tree):
+    """
+    Parse dayPeriodRule data into a dict.
+
+    :param tree: ElementTree
+    """
+    day_periods = {}
+    for ruleset in tree.findall(".//dayPeriodRuleSet"):
+        ruleset_type = ruleset.attrib.get("type")  # None|"selection"
+        for rules in ruleset.findall("dayPeriodRules"):
+            locales = rules.attrib["locales"].split()
+            for rule in rules.findall("dayPeriodRule"):
+                type = rule.attrib["type"]
+                rule = _compact_dict(dict(
+                    (key, _time_to_seconds_past_midnight(rule.attrib.get(key)))
+                    for key in ("after", "at", "before", "from", "to")
+                ))
+                for locale in locales:
+                    dest_list = day_periods.setdefault(locale, {}).setdefault(ruleset_type, {}).setdefault(type, [])
+                    dest_list.append(rule)
+    return day_periods
+
+
+
 if __name__ == '__main__':
     main()


Test patch:
diff --git a/tests/test_day_periods.py b/tests/test_day_periods.py
new file mode 100644
index 0000000..eb12b07
--- /dev/null
+++ b/tests/test_day_periods.py
@@ -0,0 +1,18 @@
+# -- encoding: UTF-8 --
+from datetime import time
+
+import babel.dates as dates
+import pytest
+
+
+@pytest.mark.parametrize("locale, time, expected_period_id", [
+    ("de", time(7, 42), "morning1"),  # (from, before)
+    ("de", time(3, 11), "night1"),  # (after, before)
+    ("fi", time(0), "midnight"),  # (at)
+    ("en_US", time(12), "noon"),  # (at)
+    ("agq", time(10), "am"),  # no periods defined
+    ("agq", time(22), "pm"),  # no periods defined
+    ("am", time(14), "afternoon1"),  # (before, after)
+])
+def test_day_period_rules(locale, time, expected_period_id):
+    assert dates.get_period_id(time, locale=locale) == expected_period_id