From 3340d0735e095df17ef9aa08c79b5bc66a4baba1 Mon Sep 17 00:00:00 2001 From: jomae Date: Fri, 19 Apr 2024 01:47:14 +0000 Subject: [PATCH 1/2] 1.6.1dev: allow to use Babel 2.10+ (closes #13482) * Format time using `a` period instead of `b` and `B` periods * Adapt unit tests to Babel 2.10+ * Unpin `Babel<2.10` of requirements git-svn-id: http://trac.edgewall.org/intertrac/log:/branches/1.6-stable@17771 af82e41b-90c4-0310-8c96-b1721e28e2e2 --- .github/requirements.txt | 2 +- setup.cfg | 2 +- trac/ticket/tests/report.py | 8 ++-- trac/util/datefmt.py | 92 +++++++++++++++++++++++++------------ trac/util/tests/datefmt.py | 84 ++++++++++++++++++++++++++------- trac/web/tests/chrome.py | 31 +++++++++++-- 6 files changed, 162 insertions(+), 57 deletions(-) diff --git a/.github/requirements.txt b/.github/requirements.txt index 012369abbd..d3d91dd7de 100644 --- a/.github/requirements.txt +++ b/.github/requirements.txt @@ -3,7 +3,7 @@ multipart; python_version>='3.11' aiosmtpd; python_version>='3.10' selenium!=4.10.0 pytidylib -Babel<2.10 +Babel Pygments docutils textile diff --git a/setup.cfg b/setup.cfg index bf9ff7c224..49d0d4f21c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -64,7 +64,7 @@ exclude = *.tests.* [options.extras_require] - babel = Babel>=2.2,<2.10 + babel = Babel>=2.2 mysql = PyMySQL postgresql = psycopg2>=2.5 psycopg2 = psycopg2>=2.5 diff --git a/trac/ticket/tests/report.py b/trac/ticket/tests/report.py index 936351aa50..ee73e63c13 100644 --- a/trac/ticket/tests/report.py +++ b/trac/ticket/tests/report.py @@ -422,11 +422,11 @@ def test_timestamp_columns(self): rendered = self._render_template(req, *rv) rendered = str(rendered, 'utf-8') self.assertRegex(rendered, - r'\s*(12:00:42 AM|00:00:42)\s*') + r'\s*(12:00:42\sAM|00:00:42)\s*') self.assertRegex(rendered, r'\s*(Jan 3, 1970|01/03/(19)?70)\s*') self.assertRegex(rendered, - r'\s*(Jan 4, 1970, 12:00:44 AM|' + r'\s*(Jan 4, 1970, 12:00:44\sAM|' r'01/04/(19)?70 00:00:44)\s*') self.assertRegex(rendered, r'\s*\s*on (Sep 13, 2021|09/13/21) ' - r'at (12:13:16 PM|12:13:16)\s*') + r'at (12:13:16\sPM|12:13:16)\s*') self.assertRegex(rendered, r'\s*on (Sep 12, 2021|09/12/21) ' - r'at (12:13:15 PM|12:13:15)\s*') + r'at (12:13:15\sPM|12:13:15)\s*') class ExecuteReportTestCase(unittest.TestCase): diff --git a/trac/util/datefmt.py b/trac/util/datefmt.py index d407b86ba5..a940235e32 100644 --- a/trac/util/datefmt.py +++ b/trac/util/datefmt.py @@ -34,6 +34,7 @@ from babel import Locale from babel.core import LOCALE_ALIASES, UnknownLocaleError from babel.dates import ( + DateTimeFormat, format_datetime as babel_format_datetime, format_date as babel_format_date, format_time as babel_format_time, @@ -44,8 +45,9 @@ ) # 'context' parameter was added in Babel 2.3.1 if 'context' in inspect.signature(babel_get_period_names).parameters: - def get_period_names(locale=None): - return babel_get_period_names(context='format', locale=locale) + def get_period_names(width='wide', locale=None): + return babel_get_period_names(width=width, context='format', + locale=locale) else: get_period_names = babel_get_period_names @@ -292,16 +294,40 @@ def _format_datetime(t, format, tzinfo, locale, hint): hint = _STRFTIME_HINTS[format] format = 'medium' if format in ('short', 'medium', 'long', 'full'): - if hint == 'datetime': - return babel_format_datetime(t, format, None, locale) - if hint == 'date': - return babel_format_date(t, format, locale) - if hint == 'time': - return babel_format_time(t, format, None, locale) + return _format_datetime_babel(t, format, locale, hint) format = _BABEL_FORMATS[hint].get(format, format) return _format_datetime_without_babel(t, format) +if babel: + class _DateTimeFormatFixup(DateTimeFormat): + def __getitem__(self, name): + if name.startswith(('b', 'B')): + return self.format_period('a', len(name)) + else: + return super().__getitem__(name) + +def _format_datetime_babel(t, format, locale, hint): + if hint in ('datetime', 'date'): + datepart = babel_format_date(t, format, locale) + if hint == 'date': + return datepart + if hint in ('datetime', 'time'): + time_format = get_time_format(format, locale) + # Use `a` period instead of `b` and `B` periods because `parse_date` + # and jQuery timepicker addon don't support the periods + if '%(b' in time_format.format or '%(B' in time_format.format: + timepart = time_format.format % _DateTimeFormatFixup(t, locale) + else: + timepart = babel_format_time(t, format, None, locale) + if hint == 'time': + return timepart + if hint == 'datetime': + return get_datetime_format(format, locale=locale) \ + .replace("'", '') \ + .replace('{0}', timepart) \ + .replace('{1}', datepart) + def format_datetime(t=None, format='%x %X', tzinfo=None, locale=None): """Format the `datetime` object `t` into a `str` string @@ -439,23 +465,28 @@ def get_time_format_jquery_ui(locale): """Get the time format for the jQuery UI timepicker addon.""" if locale == 'iso8601': return 'HH:mm:ssZ' + + t = datetime(1999, 10, 29, 23, 59, 58, tzinfo=utc) if babel and locale: values = {'h': 'h', 'hh': 'hh', 'H': 'H', 'HH': 'HH', 'm': 'm', 'mm': 'mm', 's': 's', 'ss': 'ss'} - f = get_time_format('medium', locale=locale).format - if '%(a)s' in f: - t = datetime(1999, 10, 29, 23, 59, 58, tzinfo=utc) + # Use `a` period instead of `b` and `B` periods, because jQuery + # timepicker addon doesn't support the periods. + tmpl = babel_format_time(t, tzinfo=utc, locale=locale) + if '23' not in tmpl: ampm = babel_format_datetime(t, 'a', None, locale) - values['a'] = 'TT' if ampm[0].isupper() else 'tt' + ampm = 'TT' if ampm[0].isupper() else 'tt' + values.update((period * n, ampm) for period in ('a', 'b', 'B') + for n in range(1, 6)) + f = get_time_format('medium', locale=locale).format return f % values - - t = datetime(1999, 10, 29, 23, 59, 58, tzinfo=utc) - tmpl = format_time(t, tzinfo=utc) - ampm = format_time(t, '%p', tzinfo=utc) - if ampm: - tmpl = tmpl.replace(ampm, 'TT' if ampm[0].isupper() else 'tt', 1) - return tmpl.replace('23', 'HH', 1).replace('11', 'hh', 1) \ - .replace('59', 'mm', 1).replace('58', 'ss', 1) + else: + tmpl = format_time(t, tzinfo=utc) + ampm = format_time(t, '%p', tzinfo=utc) + if ampm: + tmpl = tmpl.replace(ampm, 'TT' if ampm[0].isupper() else 'tt', 1) + return tmpl.replace('23', 'HH', 1).replace('11', 'hh', 1) \ + .replace('59', 'mm', 1).replace('58', 'ss', 1) def get_timezone_list_jquery_ui(t=None): """Get timezone list for jQuery timepicker addon""" @@ -701,20 +732,21 @@ def _i18n_parse_date_pattern(locale): if name: period_names[name.lower()] = period else: - if formats[0].find('%(MMM)s') != -1: - for width in ('wide', 'abbreviated'): - names = get_month_names(width, locale=locale) - month_names.update((name.lower(), num) - for num, name in names.items()) - if formats[0].find('%(a)s') != -1: - names = get_period_names(locale=locale) + for width in ('wide', 'abbreviated'): + names = get_month_names(width=width, locale=locale) + month_names.update((name.lower(), num) + for num, name in names.items()) + names = get_period_names(width=width, locale=locale) period_names.update((name.lower(), period) for period, name in names.items() if period in ('am', 'pm')) - regexp = ['[0-9]+'] - regexp.extend(re.escape(name) for name in month_names) - regexp.extend(re.escape(name) for name in period_names) + regexp = [] + regexp.extend(month_names) + regexp.extend(period_names) + regexp.sort(key=lambda v: len(v), reverse=True) + regexp = list(map(re.escape, regexp)) + regexp.append('[0-9]+') return { 'orders': orders, diff --git a/trac/util/tests/datefmt.py b/trac/util/tests/datefmt.py index be95a2bdd5..33ad6ddfc5 100644 --- a/trac/util/tests/datefmt.py +++ b/trac/util/tests/datefmt.py @@ -1166,7 +1166,9 @@ def test_i18n_format_datetime(self): self.assertIn(datefmt.format_datetime(t, tzinfo=tz, locale=locale_en), ('Aug 28, 2010 1:45:56 PM', - 'Aug 28, 2010, 1:45:56 PM')) # CLDR 23 + 'Aug 28, 2010, 1:45:56 PM', # CLDR 23 + 'Aug 28, 2010, 1:45:56\u202fPM', # CLDR 42 + )) en_GB = Locale.parse('en_GB') self.assertIn(datefmt.format_datetime(t, tzinfo=tz, locale=en_GB), ('28 Aug 2010 13:45:56', # Babel < 2.2.0 @@ -1174,7 +1176,8 @@ def test_i18n_format_datetime(self): fr = Locale.parse('fr') self.assertIn(datefmt.format_datetime(t, tzinfo=tz, locale=fr), ('28 août 2010 13:45:56', # Babel < 2.2.0 - '28 ao\xfbt 2010 \xe0 13:45:56')) # Babel 2.2.0 + '28 ao\xfbt 2010 \xe0 13:45:56', # Babel 2.2.0 + '28 août 2010, 13:45:56')) # Babel 2.10.0 ja = Locale.parse('ja') self.assertEqual('2010/08/28 13:45:56', datefmt.format_datetime(t, tzinfo=tz, locale=ja)) @@ -1185,7 +1188,8 @@ def test_i18n_format_datetime(self): zh_CN = Locale.parse('zh_CN') self.assertIn(datefmt.format_datetime(t, tzinfo=tz, locale=zh_CN), ('2010-8-28 下午01:45:56', - '2010年8月28日 下午1:45:56')) + '2010年8月28日 下午1:45:56', + '2010年8月28日 13:45:56')) # Babel 2.10.0 def test_i18n_format_date(self): tz = datefmt.timezone('GMT +2:00') @@ -1219,9 +1223,10 @@ def test_i18n_format_time(self): vi = Locale.parse('vi') zh_CN = Locale.parse('zh_CN') - self.assertEqual('1:45:56 PM', - datefmt.format_time(t, tzinfo=tz, - locale=locale_en)) + self.assertIn(datefmt.format_time(t, tzinfo=tz, locale=locale_en), + ('1:45:56 PM', + '1:45:56\u202fPM', # CLDR 42 + )) self.assertEqual('13:45:56', datefmt.format_time(t, tzinfo=tz, locale=en_GB)) self.assertEqual('13:45:56', @@ -1231,7 +1236,8 @@ def test_i18n_format_time(self): self.assertEqual('13:45:56', datefmt.format_time(t, tzinfo=tz, locale=vi)) self.assertIn(datefmt.format_time(t, tzinfo=tz, locale=zh_CN), - ('下午01:45:56', '下午1:45:56')) + ('下午01:45:56', '下午1:45:56', + '13:45:56')) # Babel 2.10.0 def test_i18n_datetime_hint(self): en_GB = Locale.parse('en_GB') @@ -1241,8 +1247,11 @@ def test_i18n_datetime_hint(self): zh_CN = Locale.parse('zh_CN') self.assertIn(datefmt.get_datetime_format_hint(locale_en), - ('MMM d, yyyy h:mm:ss a', 'MMM d, y h:mm:ss a', - 'MMM d, y, h:mm:ss a')) + ('MMM d, yyyy h:mm:ss a', + 'MMM d, y h:mm:ss a', + 'MMM d, y, h:mm:ss a', + 'MMM d, y, h:mm:ss\u202fa', # Babel 2.12.0 (CLDR 42) + )) self.assertIn(datefmt.get_datetime_format_hint(en_GB), ('d MMM yyyy HH:mm:ss', 'd MMM y HH:mm:ss', @@ -1250,7 +1259,8 @@ def test_i18n_datetime_hint(self): self.assertIn(datefmt.get_datetime_format_hint(fr), ('d MMM yyyy HH:mm:ss', 'd MMM y HH:mm:ss', - "d MMM y '\xe0' HH:mm:ss")) # Babel 2.2.0 + "d MMM y '\xe0' HH:mm:ss", # Babel 2.2.0 + 'd MMM y, HH:mm:ss')) # Babel 2.10.0 self.assertIn(datefmt.get_datetime_format_hint(ja), ('yyyy/MM/dd H:mm:ss', 'y/MM/dd H:mm:ss')) self.assertIn(datefmt.get_datetime_format_hint(vi), @@ -1258,7 +1268,9 @@ def test_i18n_datetime_hint(self): 'HH:mm:ss dd-MM-y', 'HH:mm:ss, d MMM, y')) # Babel 2.2.0 self.assertIn(datefmt.get_datetime_format_hint(zh_CN), - ('yyyy-M-d ahh:mm:ss', 'y年M月d日 ah:mm:ss')) + ('yyyy-M-d ahh:mm:ss', + 'y年M月d日 ah:mm:ss', + 'y年M月d日 HH:mm:ss')) # Babel 2.10.0 def test_i18n_date_hint(self): en_GB = Locale.parse('en_GB') @@ -1468,16 +1480,56 @@ def test_format_compatibility(self): # Converting default format to babel's format self.assertIn(datefmt.format_datetime(t, '%x %X', tz, locale_en), ('Aug 28, 2010 1:45:56 PM', - 'Aug 28, 2010, 1:45:56 PM')) # CLDR 23 + 'Aug 28, 2010, 1:45:56 PM', # CLDR 23 + 'Aug 28, 2010, 1:45:56\u202fPM', # CLDR 42 + )) self.assertEqual('Aug 28, 2010', datefmt.format_datetime(t, '%x', tz, locale_en)) - self.assertEqual('1:45:56 PM', - datefmt.format_datetime(t, '%X', tz, locale_en)) - self.assertEqual('Aug 28, 2010', + self.assertEqual(datefmt.format_datetime(t, '%x', tz, locale_en), datefmt.format_date(t, '%x', tz, locale_en)) - self.assertEqual('1:45:56 PM', + self.assertIn(datefmt.format_datetime(t, '%X', tz, locale_en), + ('1:45:56 PM', + '1:45:56\u202fPM', # CLDR 42 + )) + self.assertEqual(datefmt.format_datetime(t, '%X', tz, locale_en), datefmt.format_time(t, '%X', tz, locale_en)) + def test_format_a_period_instead_of_b_periods(self): + zh_TW = Locale.parse('zh_TW') + + t = datetime.datetime(2024, 4, 18, 23, 45, 56, 123456, datefmt.utc) + self.assertEqual( + '2024年4月18日', + datefmt.format_date(t, tzinfo=datefmt.utc, locale=zh_TW)) + self.assertEqual( + '下午11:45:56', + datefmt.format_time(t, tzinfo=datefmt.utc, locale=zh_TW)) + self.assertEqual( + '2024年4月18日 下午11:45:56', + datefmt.format_datetime(t, tzinfo=datefmt.utc, locale=zh_TW)) + + t = datetime.datetime(2024, 4, 19, 1, 45, 56, 123456, datefmt.utc) + self.assertEqual( + '2024年4月19日', + datefmt.format_date(t, tzinfo=datefmt.utc, locale=zh_TW)) + self.assertEqual( + '上午1:45:56', + datefmt.format_time(t, tzinfo=datefmt.utc, locale=zh_TW)) + self.assertEqual( + '2024年4月19日 上午1:45:56', + datefmt.format_datetime(t, tzinfo=datefmt.utc, locale=zh_TW)) + + t = datetime.datetime(2024, 4, 19, 12, 45, 56, 123456, datefmt.utc) + self.assertEqual( + '2024年4月19日', + datefmt.format_date(t, tzinfo=datefmt.utc, locale=zh_TW)) + self.assertEqual( + '下午12:45:56', + datefmt.format_time(t, tzinfo=datefmt.utc, locale=zh_TW)) + self.assertEqual( + '2024年4月19日 下午12:45:56', + datefmt.format_datetime(t, tzinfo=datefmt.utc, locale=zh_TW)) + def test_parse_invalid_date(self): tz = datefmt.timezone('GMT +2:00') diff --git a/trac/web/tests/chrome.py b/trac/web/tests/chrome.py index e1bf34add8..3ead0b61ff 100644 --- a/trac/web/tests/chrome.py +++ b/trac/web/tests/chrome.py @@ -33,7 +33,7 @@ from trac.util import create_file from trac.util.datefmt import pytz, timezone, utc from trac.util.html import Markup, tag -from trac.util.translation import has_babel +from trac.util.translation import get_available_locales, has_babel from trac.web.api import IRequestHandler from trac.web.chrome import ( Chrome, INavigationContributor, add_link, add_meta, add_notice, @@ -288,6 +288,27 @@ def test_add_jquery_ui_default_format(self): data = self._get_jquery_ui_script_data(locale_en) self.assertIsNone(data['timezone_list']) + @unittest.skipUnless(has_babel, 'Babel unavailable') + def test_add_jquery_ui_time_format(self): + from babel.core import Locale + data = self._get_jquery_ui_script_data(locale_en) + self.assertEqual(True, data['ampm']) + self.assertIn(data['time_format'], ('h:mm:ss TT', 'h:mm:ss\u202fTT')) + locale_ja = Locale.parse('ja') + data = self._get_jquery_ui_script_data(locale_ja) + self.assertEqual(False, data['ampm']) + self.assertEqual('H:mm:ss', data['time_format']) + locale_zh_tw = Locale.parse('zh_TW') + data = self._get_jquery_ui_script_data(locale_zh_tw) + self.assertEqual(True, data['ampm']) + self.assertEqual('tth:mm:ss', data['time_format']) + + @unittest.skipUnless(has_babel, 'Babel unavailable') + def test_add_jquery_ui_available_locales(self): + for locale in get_available_locales(): + locale = get_negotiated_locale([locale]) + data = self._get_jquery_ui_script_data(locale) + def test_invalid_default_dateinfo_format_raises_exception(self): self.env.config.set('trac', 'default_dateinfo_format', 'ābšolute') @@ -1148,12 +1169,12 @@ def test_pretty_dateinfo(self): """), content) From b3a2d05c771e7bc6570e27c0bb690d81242a1194 Mon Sep 17 00:00:00 2001 From: jomae Date: Fri, 19 Apr 2024 01:57:45 +0000 Subject: [PATCH 2/2] 1.6.1dev: follow-up to [17771], use `Locale.parse` instead of `get_negotiated_locale` (refs #13482) git-svn-id: http://trac.edgewall.org/intertrac/log:/branches/1.6-stable@17772 af82e41b-90c4-0310-8c96-b1721e28e2e2 --- trac/web/tests/chrome.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/trac/web/tests/chrome.py b/trac/web/tests/chrome.py index 3ead0b61ff..2296e44758 100644 --- a/trac/web/tests/chrome.py +++ b/trac/web/tests/chrome.py @@ -305,8 +305,9 @@ def test_add_jquery_ui_time_format(self): @unittest.skipUnless(has_babel, 'Babel unavailable') def test_add_jquery_ui_available_locales(self): + from babel.core import Locale for locale in get_available_locales(): - locale = get_negotiated_locale([locale]) + locale = Locale.parse(locale) data = self._get_jquery_ui_script_data(locale) def test_invalid_default_dateinfo_format_raises_exception(self):