Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added locale validation and translation request on untranslated timeframes #646

Merged
merged 11 commits into from
Aug 24, 2019
160 changes: 86 additions & 74 deletions arrow/arrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -829,7 +829,7 @@ def humanize(
Defaults to now in the current :class:`Arrow <arrow.arrow.Arrow>` object's timezone.
:param locale: (optional) a ``str`` specifying a locale. Defaults to 'en_us'.
:param only_distance: (optional) returns only time difference eg: "11 seconds" without "in" or "ago" part.
:param granularity: (optional) defines the precision of the output. Set it to strings 'second', 'minute', 'hour', 'day', 'month' or 'year'.
:param granularity: (optional) defines the precision of the output. Set it to strings 'second', 'minute', 'hour', 'day', 'week', 'month' or 'year'.

Usage::

Expand All @@ -843,6 +843,7 @@ def humanize(

"""

locale_name = locale
locale = locales.get_locale(locale)

if other is None:
Expand All @@ -866,80 +867,92 @@ def humanize(
diff = abs(delta)
delta = diff

if granularity == "auto":
if diff < 10:
return locale.describe("now", only_distance=only_distance)

if diff < 45:
seconds = sign * delta
return locale.describe("seconds", seconds, only_distance=only_distance)

elif diff < 90:
return locale.describe("minute", sign, only_distance=only_distance)
elif diff < 2700:
minutes = sign * int(max(delta / 60, 2))
return locale.describe("minutes", minutes, only_distance=only_distance)

elif diff < 5400:
return locale.describe("hour", sign, only_distance=only_distance)
elif diff < 79200:
hours = sign * int(max(delta / 3600, 2))
return locale.describe("hours", hours, only_distance=only_distance)

elif diff < 129600:
return locale.describe("day", sign, only_distance=only_distance)
elif diff < 554400:
days = sign * int(max(delta / 86400, 2))
return locale.describe("days", days, only_distance=only_distance)

elif diff < 907200:
return locale.describe("week", sign, only_distance=only_distance)
elif diff < 2419200:
weeks = sign * int(max(delta / 604800, 2))
return locale.describe("weeks", weeks, only_distance=only_distance)

elif diff < 3888000:
return locale.describe("month", sign, only_distance=only_distance)
elif diff < 29808000:
self_months = self._datetime.year * 12 + self._datetime.month
other_months = dt.year * 12 + dt.month

months = sign * int(max(abs(other_months - self_months), 2))

return locale.describe("months", months, only_distance=only_distance)

elif diff < 47260800:
return locale.describe("year", sign, only_distance=only_distance)
else:
years = sign * int(max(delta / 31536000, 2))
return locale.describe("years", years, only_distance=only_distance)

else:
if granularity == "second":
delta = sign * delta
if abs(delta) < 2:
try:
if granularity == "auto":
if diff < 10:
return locale.describe("now", only_distance=only_distance)
elif granularity == "minute":
delta = sign * delta / float(60)
elif granularity == "hour":
delta = sign * delta / float(60 * 60)
elif granularity == "day":
delta = sign * delta / float(60 * 60 * 24)
elif granularity == "week":
delta = sign * delta / float(60 * 60 * 24 * 7)
elif granularity == "month":
delta = sign * delta / float(60 * 60 * 24 * 30.5)
elif granularity == "year":
delta = sign * delta / float(60 * 60 * 24 * 365.25)

if diff < 45:
seconds = sign * delta
return locale.describe(
"seconds", seconds, only_distance=only_distance
)

elif diff < 90:
return locale.describe("minute", sign, only_distance=only_distance)
elif diff < 2700:
minutes = sign * int(max(delta / 60, 2))
return locale.describe(
"minutes", minutes, only_distance=only_distance
)

elif diff < 5400:
return locale.describe("hour", sign, only_distance=only_distance)
elif diff < 79200:
hours = sign * int(max(delta / 3600, 2))
return locale.describe("hours", hours, only_distance=only_distance)

elif diff < 129600:
return locale.describe("day", sign, only_distance=only_distance)
elif diff < 554400:
days = sign * int(max(delta / 86400, 2))
return locale.describe("days", days, only_distance=only_distance)

elif diff < 907200:
return locale.describe("week", sign, only_distance=only_distance)
elif diff < 2419200:
weeks = sign * int(max(delta / 604800, 2))
return locale.describe("weeks", weeks, only_distance=only_distance)

elif diff < 3888000:
return locale.describe("month", sign, only_distance=only_distance)
elif diff < 29808000:
self_months = self._datetime.year * 12 + self._datetime.month
other_months = dt.year * 12 + dt.month

months = sign * int(max(abs(other_months - self_months), 2))

return locale.describe(
"months", months, only_distance=only_distance
)

elif diff < 47260800:
return locale.describe("year", sign, only_distance=only_distance)
else:
years = sign * int(max(delta / 31536000, 2))
return locale.describe("years", years, only_distance=only_distance)

else:
raise AttributeError(
'Error. Could not understand your level of granularity. Please select between \
"second", "minute", "hour", "day", "week", "month" or "year"'
if granularity == "second":
delta = sign * delta
if abs(delta) < 2:
return locale.describe("now", only_distance=only_distance)
elif granularity == "minute":
delta = sign * delta / float(60)
elif granularity == "hour":
delta = sign * delta / float(60 * 60)
elif granularity == "day":
delta = sign * delta / float(60 * 60 * 24)
elif granularity == "week":
delta = sign * delta / float(60 * 60 * 24 * 7)
elif granularity == "month":
delta = sign * delta / float(60 * 60 * 24 * 30.5)
elif granularity == "year":
delta = sign * delta / float(60 * 60 * 24 * 365.25)
else:
raise AttributeError(
"Invalid level of granularity. Please select between 'second', 'minute', 'hour', 'day', 'week', 'month' or 'year'"
)

if trunc(abs(delta)) != 1:
granularity += "s"
return locale.describe(granularity, delta, only_distance=only_distance)
except KeyError as e:
raise ValueError(
"Humanization of the {} granularity is not currently translated in the '{}' locale. Please consider making a contribution to this locale.".format(
e, locale_name
)

if trunc(abs(delta)) != 1:
granularity += "s"
return locale.describe(granularity, delta, only_distance=only_distance)
)

# query functions

Expand Down Expand Up @@ -975,8 +988,7 @@ def is_between(self, start, end, bounds="()"):

if bounds != "()" and bounds != "(]" and bounds != "[)" and bounds != "[]":
raise AttributeError(
'Error. Could not understand the specified bounds. Please select between \
"()", "(]", "[)", or "[]"'
'Invalid bounds. Please select between "()", "(]", "[)", or "[]".'
)

if not isinstance(start, Arrow):
Expand Down
8 changes: 4 additions & 4 deletions arrow/locales.py
Original file line number Diff line number Diff line change
Expand Up @@ -1513,7 +1513,7 @@ class MacedonianLocale(SlavicBaseLocale):
]


class _DeutschLocaleCommonMixin(object):
class DeutschBaseLocale(Locale):

past = "vor {0}"
future = "in {0}"
Expand Down Expand Up @@ -1605,12 +1605,12 @@ def describe(self, timeframe, delta=0, only_distance=False):
return humanized


class GermanLocale(_DeutschLocaleCommonMixin, Locale):
class GermanLocale(DeutschBaseLocale, Locale):

names = ["de", "de_de"]


class AustrianLocale(_DeutschLocaleCommonMixin, Locale):
class AustrianLocale(DeutschBaseLocale, Locale):

names = ["de_at"]

Expand Down Expand Up @@ -3072,7 +3072,7 @@ def _map_locales():
locales = {}

for _, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass):
if issubclass(cls, Locale):
if issubclass(cls, Locale): # pragma: no branch
for name in cls.names:
locales[name.lower()] = cls

Expand Down
19 changes: 12 additions & 7 deletions arrow/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,22 +63,26 @@ def __init__(self, locale="en_us", cache_size=0):
self._input_re_map = self._BASE_INPUT_RE_MAP.copy()
self._input_re_map.update(
{
"MMMM": self._choice_re(self.locale.month_names[1:], re.IGNORECASE),
"MMM": self._choice_re(
"MMMM": self._generate_choice_re(
self.locale.month_names[1:], re.IGNORECASE
),
"MMM": self._generate_choice_re(
self.locale.month_abbreviations[1:], re.IGNORECASE
),
"Do": re.compile(self.locale.ordinal_day_re),
"dddd": self._choice_re(self.locale.day_names[1:], re.IGNORECASE),
"ddd": self._choice_re(
"dddd": self._generate_choice_re(
self.locale.day_names[1:], re.IGNORECASE
),
"ddd": self._generate_choice_re(
self.locale.day_abbreviations[1:], re.IGNORECASE
),
"d": re.compile(r"[1-7]"),
"a": self._choice_re(
"a": self._generate_choice_re(
(self.locale.meridians["am"], self.locale.meridians["pm"])
),
# note: 'A' token accepts both 'am/pm' and 'AM/PM' formats to
# ensure backwards compatibility of this token
"A": self._choice_re(self.locale.meridians.values()),
"A": self._generate_choice_re(self.locale.meridians.values()),
}
)
if cache_size > 0:
Expand Down Expand Up @@ -333,8 +337,9 @@ def _try_timestamp(string):
except Exception:
return None

# generates a capture group of choices separated by an OR operator
@staticmethod
def _choice_re(choices, flags=0):
def _generate_choice_re(choices, flags=0):
return re.compile(r"({})".format("|".join(choices)), flags=flags)


Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
backports.functools_lru_cache==1.5.0
chai==1.1.2
mock==3.0.*
nose==1.3.7
nose-cov==1.6
pre-commit==1.18.*
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ ignore = E203,E501,W503
line_length = 88
multi_line_output = 3
include_trailing_comma = true
known_third_party = chai,dateutil,pytz,setuptools,simplejson
known_third_party = chai,dateutil,mock,pytz,setuptools,simplejson

[bdist_wheel]
universal = 1
17 changes: 17 additions & 0 deletions tests/arrow_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from chai import Chai
from dateutil import tz
from dateutil.relativedelta import FR, MO, SA, SU, TH, TU, WE
from mock import patch

from arrow import arrow, util

Expand Down Expand Up @@ -1649,6 +1650,22 @@ def test_none(self):

self.assertEqual(result, "just now")

result = arw.humanize(None)

self.assertEqual(result, "just now")

def test_untranslated_granularity(self):

arw = arrow.Arrow.utcnow()
later = arw.shift(weeks=1)

# simulate an untranslated timeframe key
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it!

with patch.dict("arrow.locales.EnglishLocale.timeframes"):
del arrow.locales.EnglishLocale.timeframes["week"]

with self.assertRaises(ValueError):
arw.humanize(later, granularity="week")


class ArrowHumanizeTestsWithLocale(Chai):
def setUp(self):
Expand Down
4 changes: 4 additions & 0 deletions tests/factory_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from dateutil import tz

from arrow import factory, util
from arrow.parser import ParserError


def assertDtEqual(dt1, dt2, within=10):
Expand Down Expand Up @@ -113,6 +114,9 @@ def test_kwarg_tzinfo_string(self):

assertDtEqual(self.factory.get(tzinfo="US/Pacific"), self.expected)

with self.assertRaises(ParserError):
self.factory.get(tzinfo="US/PacificInvalidTzinfo")

def test_one_arg_iso_str(self):

dt = datetime.utcnow()
Expand Down
35 changes: 35 additions & 0 deletions tests/locales_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,41 @@
from arrow import arrow, locales


class LocaleValidationTests(Chai):
"""Validate locales to ensure that translations are valid and complete"""

def setUp(self):
super(LocaleValidationTests, self).setUp()

self.locales = locales._locales

def test_locale_validation(self):

for _, locale_cls in self.locales.items():
# 7 days + 1 spacer to allow for 1-indexing of months
self.assertEqual(len(locale_cls.day_names), 8)
self.assertTrue(locale_cls.day_names[0] == "")
# ensure that all string from index 1 onward are valid (not blank or None)
self.assertTrue(all(locale_cls.day_names[1:]))

self.assertEqual(len(locale_cls.day_abbreviations), 8)
self.assertTrue(locale_cls.day_abbreviations[0] == "")
self.assertTrue(all(locale_cls.day_abbreviations[1:]))

# 12 months + 1 spacer to allow for 1-indexing of months
self.assertEqual(len(locale_cls.month_names), 13)
self.assertTrue(locale_cls.month_names[0] == "")
self.assertTrue(all(locale_cls.month_names[1:]))

self.assertEqual(len(locale_cls.month_abbreviations), 13)
self.assertTrue(locale_cls.month_abbreviations[0] == "")
self.assertTrue(all(locale_cls.month_abbreviations[1:]))

self.assertTrue(len(locale_cls.names) > 0)
self.assertTrue(locale_cls.past is not None)
self.assertTrue(locale_cls.future is not None)


class ModuleTests(Chai):
def test_get_locale(self):

Expand Down