diff --git a/trac/ticket/admin.py b/trac/ticket/admin.py index 65351e910a..26acb414cb 100644 --- a/trac/ticket/admin.py +++ b/trac/ticket/admin.py @@ -19,9 +19,9 @@ from trac.resource import ResourceNotFound from trac.ticket import model from trac.util import getuser -from trac.util.datefmt import utc, parse_date, get_date_format_hint, \ - get_datetime_format_hint, format_date, \ - format_datetime +from trac.util.datefmt import utc, parse_date, format_date, format_datetime, \ + i18n_get_datetime_format_hint, \ + i18n_get_date_format_hint, i18n_parse_date from trac.util.text import print_table, printout, exception_to_unicode from trac.util.translation import _, N_, gettext from trac.web.chrome import Chrome, add_notice, add_warning @@ -262,10 +262,13 @@ def _render_admin_panel(self, req, cat, page, milestone): mil.due = mil.completed = None due = req.args.get('duedate', '') if due: - mil.due = parse_date(due, req.tz) + mil.due = i18n_parse_date(due, tzinfo=req.tz, + locale=req.locale) if req.args.get('completed', False): completed = req.args.get('completeddate', '') - mil.completed = parse_date(completed, req.tz) + mil.completed = i18n_parse_date(completed, + tzinfo=req.tz, + locale=req.locale) if mil.completed > datetime.now(utc): raise TracError(_('Completion date may not be in ' 'the future'), @@ -293,8 +296,9 @@ def _render_admin_panel(self, req, cat, page, milestone): mil = model.Milestone(self.env) mil.name = name if req.args.get('duedate'): - mil.due = parse_date(req.args.get('duedate'), - req.tz) + mil.due = i18n_parse_date(req.args.get('duedate'), + tzinfo=req.tz, + locale=req.locale) mil.insert() add_notice(req, _('The milestone "%(name)s" has been ' 'added.', name=name)) @@ -345,8 +349,8 @@ def do_remove(db): 'default': default} data.update({ - 'date_hint': get_date_format_hint(), - 'datetime_hint': get_datetime_format_hint() + 'date_hint': i18n_get_date_format_hint(locale=req.locale), + 'datetime_hint': i18n_get_datetime_format_hint(locale=req.locale), }) return 'admin_milestones.html', data @@ -449,7 +453,9 @@ def _render_admin_panel(self, req, cat, page, version): if req.args.get('save'): ver.name = req.args.get('name') if req.args.get('time'): - ver.time = parse_date(req.args.get('time'), req.tz) + ver.time = i18n_parse_date(req.args.get('time'), + tzinfo=req.tz, + locale=req.locale) else: ver.time = None # unset ver.description = req.args.get('description') @@ -474,8 +480,9 @@ def _render_admin_panel(self, req, cat, page, version): ver = model.Version(self.env) ver.name = name if req.args.get('time'): - ver.time = parse_date(req.args.get('time'), - req.tz) + ver.time = i18n_parse_date(req.args.get('time'), + tzinfo=req.tz, + locale=req.locale) ver.insert() add_notice(req, _('The version "%(name)s" has been ' 'added.', name=name)) @@ -516,7 +523,7 @@ def do_remove(db): 'default': default} data.update({ - 'datetime_hint': get_datetime_format_hint() + 'datetime_hint': i18n_get_datetime_format_hint(locale=req.locale), }) return 'admin_versions.html', data diff --git a/trac/ticket/query.py b/trac/ticket/query.py index 56c74ec18e..d69976769b 100644 --- a/trac/ticket/query.py +++ b/trac/ticket/query.py @@ -32,7 +32,7 @@ from trac.ticket.api import TicketSystem from trac.util import Ranges, as_bool from trac.util.datefmt import format_datetime, from_utimestamp, parse_date, \ - to_timestamp, to_utimestamp, utc + to_timestamp, to_utimestamp, utc, i18n_parse_date from trac.util.presentation import Paginator from trac.util.text import empty, shorten_line, unicode_unquote from trac.util.translation import _, tag_ @@ -422,6 +422,11 @@ def get_sql(self, req=None, cached_ids=None): """Return a (sql, params) tuple for the query.""" self.get_columns() db = self.env.get_db_cnx() + tz = None + locale = None + if req: + tz = req.tz + locale = req.locale enum_columns = ('resolution', 'priority', 'severity') # Build the list of actual columns to query @@ -469,7 +474,8 @@ def add_cols(*args): def get_timestamp(date): if date: try: - return to_utimestamp(parse_date(date, req.tz)) + return to_utimestamp(i18n_parse_date(date, tzinfo=tz, + locale=locale)) except TracError, e: errors.append(unicode(e)) return None diff --git a/trac/ticket/roadmap.py b/trac/ticket/roadmap.py index 26ee7bc3a1..741cf4044d 100644 --- a/trac/ticket/roadmap.py +++ b/trac/ticket/roadmap.py @@ -30,8 +30,9 @@ from trac.resource import * from trac.search import ISearchSource, search_to_sql, shorten_result from trac.util.datefmt import parse_date, utc, to_utimestamp, \ - get_date_format_hint, get_datetime_format_hint, \ - format_date, format_datetime, from_utimestamp + format_date, format_datetime, from_utimestamp, \ + i18n_get_datetime_format_hint, \ + i18n_get_date_format_hint, i18n_parse_date from trac.util.text import CRLF from trac.util.translation import _, tag_ from trac.ticket import Milestone, Ticket, TicketSystem, group_milestones @@ -626,7 +627,11 @@ def _do_save(self, req, db, milestone): milestone.description = req.args.get('description', '') due = req.args.get('duedate', '') - milestone.due = due and parse_date(due, tzinfo=req.tz) or None + if due: + milestone.due = i18n_parse_date(due, tzinfo=req.tz, + locale=req.locale) + else: + milestone.due = None completed = req.args.get('completeddate', '') retarget_to = req.args.get('target') @@ -658,7 +663,11 @@ def warn(msg): # -- check completed date if 'completed' in req.args: - completed = completed and parse_date(completed, req.tz) or None + if completed: + completed = i18n_parse_date(completed, tzinfo=req.tz, + locale=req.locale) + else: + completed = None if completed and completed > datetime.now(utc): warn(_('Completion date may not be in the future')) else: @@ -703,8 +712,8 @@ def _render_confirm(self, req, db, milestone): def _render_editor(self, req, db, milestone): data = { 'milestone': milestone, - 'date_hint': get_date_format_hint(), - 'datetime_hint': get_datetime_format_hint(), + 'date_hint': i18n_get_date_format_hint(locale=req.locale), + 'datetime_hint': i18n_get_datetime_format_hint(locale=req.locale), 'milestone_groups': [], } diff --git a/trac/ticket/tests/query.py b/trac/ticket/tests/query.py index 799a54048f..987d8dd91e 100644 --- a/trac/ticket/tests/query.py +++ b/trac/ticket/tests/query.py @@ -5,6 +5,11 @@ from trac.web.href import Href from trac.wiki.formatter import LinkFormatter +try: + from babel import Locale +except ImportError: + Locale = None + import unittest import difflib @@ -33,7 +38,8 @@ def assertEqualSQL(self, sql, correct_sql): def setUp(self): self.env = EnvironmentStub(default_data=True) self.db = self.env.get_db_cnx() - self.req = Mock(href=self.env.href, authname='anonymous', tz=utc) + self.req = Mock(href=self.env.href, authname='anonymous', tz=utc, + locale=Locale and Locale.parse('en_US') or None) def tearDown(self): self.env.reset_db() diff --git a/trac/timeline/web_ui.py b/trac/timeline/web_ui.py index 1294809847..ab1e448545 100644 --- a/trac/timeline/web_ui.py +++ b/trac/timeline/web_ui.py @@ -30,7 +30,7 @@ from trac.timeline.api import ITimelineEventProvider from trac.util import as_int from trac.util.datefmt import format_date, format_datetime, parse_date, \ - to_utimestamp, utc, pretty_timedelta + to_utimestamp, utc, pretty_timedelta, i18n_parse_date from trac.util.text import exception_to_unicode, to_unicode from trac.util.translation import _, tag_ from trac.web import IRequestHandler, IRequestFilter @@ -99,7 +99,8 @@ def process_request(self, req): # Acquire from date only from non-blank input reqfromdate = req.args['from'].strip() if reqfromdate: - precisedate = parse_date(reqfromdate, req.tz) + precisedate = i18n_parse_date(reqfromdate, tzinfo=req.tz, + locale=req.locale) fromdate = precisedate precision = req.args.get('precision', '') if precision.startswith('second'): diff --git a/trac/util/datefmt.py b/trac/util/datefmt.py index 0fe32b9d92..38a586defc 100644 --- a/trac/util/datefmt.py +++ b/trac/util/datefmt.py @@ -24,9 +24,19 @@ import time from datetime import tzinfo, timedelta, datetime, date +try: + import babel + from babel.dates import format_datetime as babel_format_datetime, \ + format_date as babel_format_date, \ + format_time as babel_format_time, \ + get_datetime_format, get_date_format, \ + get_time_format, get_month_names, get_period_names +except ImportError: + babel = None + from trac.core import TracError from trac.util.text import to_unicode -from trac.util.translation import _, ngettext +from trac.util.translation import _, ngettext, get_available_locales # Date/time utilities @@ -63,6 +73,16 @@ def to_datetime(t, tzinfo=None): raise TypeError('expecting datetime, int, long, float, or None; got %s' % type(t)) +def to_datetime_tz(t, tzinfo=None): + t = to_datetime(t, tzinfo) + if t.tzinfo is None: + t = t.replace(tzinfo=utc) + if tzinfo is not None: + t = t.astimezone(tzinfo) + if hasattr(tzinfo, 'normalize'): # pytz + t = tzinfo.normalize(t) + return t + def to_timestamp(dt): """Return the corresponding POSIX timestamp""" if dt: @@ -118,7 +138,7 @@ def pretty_timedelta(time1, time2=None, resolution=None): return format_units(r) return '' - + def format_datetime(t=None, format='%x %X', tzinfo=None): """Format the `datetime` object `t` into an `unicode` string @@ -193,6 +213,69 @@ def get_datetime_format_hint(): .replace('23', 'hh', 1).replace('11', 'hh', 1) \ .replace('59', 'mm', 1).replace('58', 'ss', 1) +_babel_formats = ('short', 'medium', 'long', 'full') + +def i18n_format_datetime(t=None, format='medium', tzinfo=None, locale=None): + if babel is None or locale is None: + if format in _babel_formats: + format = '%x %X' + return format_datetime(t, format, tzinfo) + + if format: + if format == '%x %X': + format = 'medium' + elif format not in _babel_formats: + return format_datetime(t, format, tzinfo) + + return babel_format_datetime(t, format, tzinfo, locale) + +def i18n_format_date(t=None, format='medium', tzinfo=None, locale=None): + if babel is None or locale is None: + if format in ('full', 'long', 'medium', 'short'): + format = '%x' + return format_date(t, format, tzinfo) + + if format: + if format == '%x': + format = 'medium' + elif format not in _babel_formats: + return format_date(t, format, tzinfo) + + t = to_datetime_tz(t, tzinfo) + return babel_format_date(t, format, locale) + +def i18n_format_time(t=None, format='medium', tzinfo=None, locale=None): + if babel is None or locale is None: + if format in ('full', 'long', 'medium', 'short'): + format = '%X' + return format_time(t, format, tzinfo) + + if format: + if format == '%x': + format = 'medium' + elif format not in _babel_formats: + return format_time(t, format, tzinfo) + + t = to_datetime_tz(t, tzinfo) + return babel_format_time(t, format, None, locale) + +def i18n_get_date_format_hint(locale=None): + if babel is None or locale is None: + return get_date_format_hint() + + format = get_date_format('medium', locale=locale) + return format.pattern + +def i18n_get_datetime_format_hint(locale=None): + if babel is None or locale is None: + return get_datetime_format_hint() + + date_pattern = get_date_format('medium', locale=locale).pattern + time_pattern = get_time_format('medium', locale=locale).pattern + format = get_datetime_format('medium', locale=locale) + return format.replace('{0}', time_pattern) \ + .replace('{1}', date_pattern) + def http_date(t=None): """Format `datetime` object `t` as a rfc822 timestamp""" t = to_datetime(t).astimezone(utc) @@ -212,11 +295,7 @@ def http_date(t=None): (Z?(?:([-+])?(\d\d):?(\d\d)?)?)?$ # timezone ''', re.VERBOSE) -def parse_date(text, tzinfo=None): - tzinfo = tzinfo or localtz - dt = None - text = text.strip() - # normalize ISO time +def _parse_date_iso8601(text, tzinfo): match = _ISO_8601_RE.match(text) if match: try: @@ -238,9 +317,16 @@ def parse_date(text, tzinfo=None): tm = time.strptime('%s ' * 6 % (years, months, days, hours, minutes, seconds), '%Y %m %d %H %M %S ') - dt = tzinfo.localize(datetime(*tm[0:6])) + return tzinfo.localize(datetime(*tm[0:6])) except ValueError: pass + + return None + +def parse_date(text, tzinfo=None): + tzinfo = tzinfo or localtz + text = text.strip() + dt = _parse_date_iso8601(text, tzinfo) if dt is None: for format in ['%x %X', '%x, %X', '%X %x', '%X, %x', '%x', '%c', '%b %d, %Y']: @@ -267,6 +353,160 @@ def parse_date(text, tzinfo=None): _('Invalid Date')) return dt +def _i18n_parse_date_patterns(): + if babel is None: + return None + + format_keys = { + 'y': ('y', 'Y'), + 'M': ('M',), + 'd': ('d',), + 'h': ('h', 'H'), + 'm': ('m',), + 's': ('s',), + } + patterns = {} + + for locale in get_available_locales(): + regexp = [r'[0-9]+'] + + date_format = get_date_format('medium', locale=locale) + time_format = get_time_format('medium', locale=locale) + datetime_format = get_datetime_format('medium', locale=locale) + + formats = ( + datetime_format.replace('{0}', time_format.format) \ + .replace('{1}', date_format.format), + date_format.format) + + orders = [] + for format in formats: + order = [] + for key, chars in format_keys.iteritems(): + for char in chars: + idx = format.find('%(' + char) + if idx != -1: + order.append((idx, key)) + break + order.sort() + order = dict((key, idx) for idx, (_, key) in enumerate(order)) + orders.append(order) + + month_names = { + 'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6, + 'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12, + } + if formats[0].find('%(MMM)s') != -1: + for width in ('wide', 'abbreviated'): + names = get_month_names(width, locale=locale) + for num, name in names.iteritems(): + name = name.lower() + month_names[name] = num + regexp.extend(month_names.iterkeys()) + + period_names = {'am': 'am', 'pm': 'pm'} + if formats[0].find('%(a)s') != -1: + names = get_period_names(locale=locale) + for period, name in names.iteritems(): + name = name.lower() + period_names[name] = period + regexp.extend(period_names.iterkeys()) + + patterns[locale] = { + 'orders': orders, + 'regexp': re.compile('(%s)' % '|'.join(regexp), + re.IGNORECASE | re.UNICODE), + 'month_names': month_names, + 'period_names': period_names, + } + + return patterns + +_I18N_PARSE_DATE_PATTERNS = _i18n_parse_date_patterns() + +def i18n_parse_date(text, tzinfo=None, locale=None): + text = text.strip() + + if babel is None or locale is None: + return parse_date(text, tzinfo=tzinfo) + + pattern = _I18N_PARSE_DATE_PATTERNS.get(str(locale)) + if not pattern: + return parse_date(text, tzinfo=tzinfo) + + dt = _parse_date_iso8601(text, tzinfo) + if dt is None: + for order in pattern['orders']: + try: + dt = _i18n_parse_date(text, order, pattern, tzinfo) + break + except ValueError: + pass + if dt is None: + dt = _parse_relative_time(text, tzinfo) + + if dt is None: + hint = i18n_get_date_format_hint(locale=locale) + raise TracError(_('"%(date)s" is an invalid date, or the date format ' + 'is not known. Try "%(hint)s" instead.', + date=text, hint=hint), _('Invalid Date')) + + try: + to_datetime(to_timestamp(dt), tzinfo) + except ValueError: + raise TracError(_('The date "%(date)s" is outside valid range. ' + 'Try a date closer to present time.', date=text), + _('Invalid Date')) + return dt + +def _i18n_parse_date(text, order, pattern, tzinfo): + matches = pattern['regexp'].findall(text.lower()) + + period_names = pattern['period_names'] + period = None + for idx, match in enumerate(matches): + period = period_names.get(match) + if period is not None: + del matches[idx] + break + + if len(matches) == 5: + matches.insert(order['s'], 0) + + values = {} + for key, idx in order.iteritems(): + if idx < len(matches): + value = matches[idx] + if key == 'y': + if len(value) == 2 and value.isdigit(): + value = '20' + value + values[key] = value + + if 'y' not in values or 'M' not in values or 'd' not in values: + raise ValueError + + month_names = pattern['month_names'] + for key in ('y', 'M', 'd'): + value = values[key].lower() + value = month_names.get(value) + if value is not None: + if key == 'M': + values[key] = value + else: + values[key], values['M'] = values['M'], value + break + + values = dict((key, int(value)) for key, value in values.iteritems()) + values.setdefault('h', 0) + values.setdefault('m', 0) + values.setdefault('s', 0) + + if values['h'] < 12 and period == 'pm': + values['h'] += 12 + + return tzinfo.localize(datetime(values['y'], values['M'], values['d'], + values['h'], values['m'], values['s'])) + _REL_TIME_RE = re.compile( r'(\d+\.?\d*)\s*' diff --git a/trac/util/tests/datefmt.py b/trac/util/tests/datefmt.py index 1af7cbb6d4..81c9146a71 100644 --- a/trac/util/tests/datefmt.py +++ b/trac/util/tests/datefmt.py @@ -145,6 +145,315 @@ def test_sub_second(self): self.assertEqual(t, datefmt.from_utimestamp(ts)) +try: + from babel import Locale +except: + I18nDateFormatTestCase = None +else: + class I18nDateFormatTestCase(unittest.TestCase): + def test_i18n_format_datetime(self): + tz = datefmt.timezone('GMT +2:00') + t = datetime.datetime(2010, 8, 28, 11, 45, 56, 123456, datefmt.utc) + en_US = Locale.parse('en_US') + self.assertEqual('Aug 28, 2010 1:45:56 PM', + datefmt.i18n_format_datetime(t, tzinfo=tz, + locale=en_US)) + en_GB = Locale.parse('en_GB') + self.assertEqual('28 Aug 2010 13:45:56', + datefmt.i18n_format_datetime(t, tzinfo=tz, + locale=en_GB)) + fr = Locale.parse('fr') + self.assertEqual(u'28 août 2010 13:45:56', + datefmt.i18n_format_datetime(t, tzinfo=tz, + locale=fr)) + ja = Locale.parse('ja') + self.assertEqual(u'2010/08/28 13:45:56', + datefmt.i18n_format_datetime(t, tzinfo=tz, + locale=ja)) + vi = Locale.parse('vi') + self.assertEqual(u'13:45:56 28-08-2010', + datefmt.i18n_format_datetime(t, tzinfo=tz, + locale=vi)) + zh_CN = Locale.parse('zh_CN') + self.assertEqual(u'2010-8-28 下午01:45:56', + datefmt.i18n_format_datetime(t, tzinfo=tz, + locale=zh_CN)) + + def test_i18n_format_date(self): + tz = datefmt.timezone('GMT +2:00') + t = datetime.datetime(2010, 8, 7, 11, 45, 56, 123456, datefmt.utc) + en_US = Locale.parse('en_US') + self.assertEqual('Aug 7, 2010', + datefmt.i18n_format_date(t, tzinfo=tz, + locale=en_US)) + en_GB = Locale.parse('en_GB') + self.assertEqual('7 Aug 2010', + datefmt.i18n_format_date(t, tzinfo=tz, + locale=en_GB)) + fr = Locale.parse('fr') + self.assertEqual(u'7 août 2010', + datefmt.i18n_format_date(t, tzinfo=tz, + locale=fr)) + ja = Locale.parse('ja') + self.assertEqual(u'2010/08/07', + datefmt.i18n_format_date(t, tzinfo=tz, + locale=ja)) + vi = Locale.parse('vi') + self.assertEqual(u'07-08-2010', + datefmt.i18n_format_date(t, tzinfo=tz, + locale=vi)) + zh_CN = Locale.parse('zh_CN') + self.assertEqual(u'2010-8-7', + datefmt.i18n_format_date(t, tzinfo=tz, + locale=zh_CN)) + + def test_i18n_format_time(self): + tz = datefmt.timezone('GMT +2:00') + t = datetime.datetime(2010, 8, 28, 11, 45, 56, 123456, datefmt.utc) + en_US = Locale.parse('en_US') + en_GB = Locale.parse('en_GB') + fr = Locale.parse('fr') + ja = Locale.parse('ja') + vi = Locale.parse('vi') + zh_CN = Locale.parse('zh_CN') + + self.assertEqual('1:45:56 PM', + datefmt.i18n_format_time(t, tzinfo=tz, + locale=en_US)) + self.assertEqual('13:45:56', + datefmt.i18n_format_time(t, tzinfo=tz, + locale=en_GB)) + self.assertEqual('13:45:56', + datefmt.i18n_format_time(t, tzinfo=tz, + locale=fr)) + self.assertEqual('13:45:56', + datefmt.i18n_format_time(t, tzinfo=tz, + locale=ja)) + self.assertEqual('13:45:56', + datefmt.i18n_format_time(t, tzinfo=tz, + locale=vi)) + self.assertEqual(u'下午01:45:56', + datefmt.i18n_format_time(t, tzinfo=tz, + locale=zh_CN)) + + def test_i18n_datetime_hint(self): + en_US = Locale.parse('en_US') + en_GB = Locale.parse('en_GB') + fr = Locale.parse('fr') + ja = Locale.parse('ja') + vi = Locale.parse('vi') + zh_CN = Locale.parse('zh_CN') + + self.assertEqual('MMM d, yyyy h:mm:ss a', + datefmt.i18n_get_datetime_format_hint(en_US)) + self.assertEqual('d MMM yyyy HH:mm:ss', + datefmt.i18n_get_datetime_format_hint(en_GB)) + self.assertEqual('d MMM yyyy HH:mm:ss', + datefmt.i18n_get_datetime_format_hint(fr)) + self.assertEqual('yyyy/MM/dd H:mm:ss', + datefmt.i18n_get_datetime_format_hint(ja)) + self.assertEqual('HH:mm:ss dd-MM-yyyy', + datefmt.i18n_get_datetime_format_hint(vi)) + self.assertEqual('yyyy-M-d ahh:mm:ss', + datefmt.i18n_get_datetime_format_hint(zh_CN)) + + def test_i18n_date_hint(self): + en_US = Locale.parse('en_US') + en_GB = Locale.parse('en_GB') + fr = Locale.parse('fr') + ja = Locale.parse('ja') + vi = Locale.parse('vi') + zh_CN = Locale.parse('zh_CN') + + self.assertEqual('MMM d, yyyy', + datefmt.i18n_get_date_format_hint(en_US)) + self.assertEqual('d MMM yyyy', + datefmt.i18n_get_date_format_hint(en_GB)) + self.assertEqual('d MMM yyyy', + datefmt.i18n_get_date_format_hint(fr)) + self.assertEqual('yyyy/MM/dd', + datefmt.i18n_get_date_format_hint(ja)) + self.assertEqual('dd-MM-yyyy', + datefmt.i18n_get_date_format_hint(vi)) + self.assertEqual('yyyy-M-d', + datefmt.i18n_get_date_format_hint(zh_CN)) + + def test_i18n_parse_date_iso8609(self): + tz = datefmt.timezone('GMT +2:00') + dt = datetime.datetime(2010, 8, 28, 13, 45, 56, 0, tz) + d = datetime.datetime(2010, 8, 28, 0, 0, 0, 0, tz) + en_US = Locale.parse('en_US') + vi = Locale.parse('vi') + + def iso8601(expected, text, tz, locale): + self.assertEqual(expected, + datefmt.i18n_parse_date(text, tzinfo=tz, + locale=locale)) + + iso8601(dt, '2010-08-28T15:45:56+0400', tz, en_US) + iso8601(dt, '2010-08-28T11:45:56+0000', tz, vi) + iso8601(dt, '2010-08-28T11:45:56Z', tz, vi) + iso8601(dt, '20100828T144556+0300', tz, en_US) + iso8601(dt, '20100828T114556Z', tz, vi) + + iso8601(d, '2010-08-28+0200', tz, en_US) + # iso8601(d, '2010-08-28+0000', tz, vi) + # iso8601(d, '2010-08-28Z', tz, en_US) + iso8601(d, '2010-08-28', tz, vi) + iso8601(d, '20100828+0200', tz, en_US) + # iso8601(d, '20100828Z', tz, vi) + + def test_i18n_parse_date_datetime(self): + tz = datefmt.timezone('GMT +2:00') + expected = datetime.datetime(2010, 8, 28, 13, 45, 56, 0, tz) + expected_minute = datetime.datetime(2010, 8, 28, 13, 45, 0, 0, tz) + en_US = Locale.parse('en_US') + en_GB = Locale.parse('en_GB') + fr = Locale.parse('fr') + ja = Locale.parse('ja') + vi = Locale.parse('vi') + zh_CN = Locale.parse('zh_CN') + + self.assertEqual(expected, + datefmt.i18n_parse_date('Aug 28, 2010 1:45:56 PM', + tzinfo=tz, locale=en_US)) + self.assertEqual(expected, + datefmt.i18n_parse_date('8 28, 2010 1:45:56 PM', + tzinfo=tz, locale=en_US)) + self.assertEqual(expected, + datefmt.i18n_parse_date('28 Aug 2010 1:45:56 PM', + tzinfo=tz, locale=en_US)) + self.assertEqual(expected, + datefmt.i18n_parse_date('28 Aug 2010 PM 1:45:56', + tzinfo=tz, locale=en_US)) + self.assertEqual(expected, + datefmt.i18n_parse_date('28 Aug 2010 13:45:56', + tzinfo=tz, locale=en_US)) + self.assertEqual(expected_minute, + datefmt.i18n_parse_date('28 Aug 2010 PM 1:45', + tzinfo=tz, locale=en_US)) + + self.assertEqual(expected, + datefmt.i18n_parse_date('28 Aug 2010 13:45:56', + tzinfo=tz, locale=en_GB)) + + self.assertEqual(expected, + datefmt.i18n_parse_date(u'28 août 2010 13:45:56', + tzinfo=tz, locale=fr)) + self.assertEqual(expected, + datefmt.i18n_parse_date(u'août 28 2010 13:45:56', + tzinfo=tz, locale=fr)) + self.assertEqual(expected_minute, + datefmt.i18n_parse_date(u'août 28 2010 13:45', + tzinfo=tz, locale=fr)) + + self.assertEqual(expected, + datefmt.i18n_parse_date('2010/08/28 13:45:56', + tzinfo=tz, locale=ja)) + self.assertEqual(expected_minute, + datefmt.i18n_parse_date('2010/08/28 13:45', + tzinfo=tz, locale=ja)) + + self.assertEqual(expected, + datefmt.i18n_parse_date('13:45:56 28-08-2010', + tzinfo=tz, locale=vi)) + self.assertEqual(expected_minute, + datefmt.i18n_parse_date('13:45 28-08-2010', + tzinfo=tz, locale=vi)) + + self.assertEqual(expected, + datefmt.i18n_parse_date(u'2010-8-28 下午01:45:56', + tzinfo=tz, locale=zh_CN)) + self.assertEqual(expected, + datefmt.i18n_parse_date(u'2010-8-28 01:45:56下午', + tzinfo=tz, locale=zh_CN)) + self.assertEqual(expected_minute, + datefmt.i18n_parse_date(u'2010-8-28 下午01:45', + tzinfo=tz, locale=zh_CN)) + self.assertEqual(expected_minute, + datefmt.i18n_parse_date(u'2010-8-28 01:45下午', + tzinfo=tz, locale=zh_CN)) + + def test_i18n_parse_date_date(self): + tz = datefmt.timezone('GMT +2:00') + expected = datetime.datetime(2010, 8, 28, 0, 0, 0, 0, tz) + en_US = Locale.parse('en_US') + en_GB = Locale.parse('en_GB') + fr = Locale.parse('fr') + ja = Locale.parse('ja') + vi = Locale.parse('vi') + zh_CN = Locale.parse('zh_CN') + + self.assertEqual(expected, + datefmt.i18n_parse_date('Aug 28, 2010', + tzinfo=tz, locale=en_US)) + self.assertEqual(expected, + datefmt.i18n_parse_date('28 Aug 2010', + tzinfo=tz, locale=en_GB)) + self.assertEqual(expected, + datefmt.i18n_parse_date(u'28 août 2010', + tzinfo=tz, locale=fr)) + self.assertEqual(expected, + datefmt.i18n_parse_date('2010/08/28', + tzinfo=tz, locale=ja)) + self.assertEqual(expected, + datefmt.i18n_parse_date('28-08-2010', + tzinfo=tz, locale=vi)) + self.assertEqual(expected, + datefmt.i18n_parse_date(u'2010-8-28', + tzinfo=tz, locale=zh_CN)) + + def test_i18n_parse_date_roundtrip(self): + tz = datefmt.timezone('GMT +2:00') + t = datetime.datetime(2010, 8, 28, 11, 45, 56, 123456, datefmt.utc) + expected = datetime.datetime(2010, 8, 28, 13, 45, 56, 0, tz) + + def roundtrip(locale): + locale = Locale.parse(locale) + formatted = datefmt.i18n_format_datetime(t, tzinfo=tz, + locale=locale) + self.assertEqual(expected, + datefmt.i18n_parse_date(formatted, tzinfo=tz, + locale=locale)) + self.assertEqual(formatted, + datefmt.i18n_format_datetime(expected, + tzinfo=tz, + locale=locale)) + + roundtrip('ca') + roundtrip('cs') + roundtrip('de') + roundtrip('el') + roundtrip('en_GB') + roundtrip('en_US') + roundtrip('eo') + roundtrip('es') + roundtrip('es_AR') + roundtrip('fa') + roundtrip('fi') + roundtrip('fr') + roundtrip('gl') + roundtrip('he') + roundtrip('hu') + roundtrip('hy') + roundtrip('it') + roundtrip('ja') + roundtrip('ko') + roundtrip('nb') + roundtrip('nl') + roundtrip('pl') + roundtrip('pt') + roundtrip('pt_BR') + roundtrip('ro') + roundtrip('ru') + roundtrip('sl') + roundtrip('sv') + roundtrip('tr') + roundtrip('vi') + roundtrip('zh_CN') + roundtrip('zh_TW') + + def suite(): suite = unittest.TestSuite() if PytzTestCase: @@ -153,6 +462,10 @@ def suite(): print "SKIP: utils/tests/datefmt.py (no pytz installed)" suite.addTest(unittest.makeSuite(DateFormatTestCase)) suite.addTest(unittest.makeSuite(UTimestampTestCase)) + if I18nDateFormatTestCase: + suite.addTest(unittest.makeSuite(I18nDateFormatTestCase, 'test')) + else: + print "SKIP: utils/tests/datefmt.py (no babel installed)" return suite if __name__ == '__main__': diff --git a/trac/web/chrome.py b/trac/web/chrome.py index 8dceedd401..67f0a6cf84 100644 --- a/trac/web/chrome.py +++ b/trac/web/chrome.py @@ -46,7 +46,9 @@ shorten_line, unicode_quote_plus, to_unicode, \ javascript_quote, exception_to_unicode from trac.util.datefmt import pretty_timedelta, format_datetime, format_date, \ - format_time, from_utimestamp, http_date, utc + format_time, from_utimestamp, http_date, utc, \ + i18n_format_datetime, i18n_format_date, \ + i18n_format_time from trac.util.translation import _ from trac.web.api import IRequestHandler, ITemplateStreamFilter, HTTPNotFound from trac.web.href import Href @@ -703,8 +705,10 @@ def populate_data(self, req, data): exception_to_unicode(e)) show_email_addresses = False tzinfo = None + locale = None if req: tzinfo = req.tz + locale = req.locale def dateinfo(date): return tag.span(pretty_timedelta(date), @@ -729,7 +733,7 @@ def get_abs_url(resource, **kwargs): 'href': href, 'perm': req and req.perm, 'authname': req and req.authname or '', - 'locale': req and req.locale, + 'locale': locale, 'show_email_addresses': show_email_addresses, 'show_ip_addresses': self.show_ip_addresses, 'authorinfo': partial(self.authorinfo, req), @@ -740,9 +744,10 @@ def get_abs_url(resource, **kwargs): # Date/time formatting 'dateinfo': dateinfo, - 'format_datetime': partial(format_datetime, tzinfo=tzinfo), - 'format_date': partial(format_date, tzinfo=tzinfo), - 'format_time': partial(format_time, tzinfo=tzinfo), + 'format_datetime': partial(i18n_format_datetime, tz=tzinfo, + locale=locale), + 'format_date': partial(i18n_format_date, tz=tzinfo, locale=locale), + 'format_time': partial(i18n_format_time, tz=tzinfo, locale=locale), 'fromtimestamp': partial(datetime.datetime.fromtimestamp, tz=tzinfo), 'from_utimestamp': from_utimestamp, diff --git a/trac/wiki/tests/formatter.py b/trac/wiki/tests/formatter.py index 2a43092759..39c947edf0 100644 --- a/trac/wiki/tests/formatter.py +++ b/trac/wiki/tests/formatter.py @@ -4,6 +4,11 @@ import unittest from datetime import datetime +try: + from babel import Locale +except ImportError: + Locale = None + from trac.core import * from trac.mimeview import Context from trac.test import Mock, MockPerm, EnvironmentStub @@ -114,7 +119,8 @@ def __init__(self, title, input, correct, file, line, setup=None, self._teardown = teardown req = Mock(href=Href('/'), abs_href=Href('http://www.example.com/'), - authname='anonymous', perm=MockPerm(), args={}) + authname='anonymous', perm=MockPerm(), args={}, tz=utc, + locale=Locale and Locale.parse('en_US') or None) if context: if isinstance(context, tuple): context = Context.from_request(req, *context)