Repository: sphinx-doc/sphinx

Issue:
Formats accepted by sphinx.util.i18n.format_date are limited
[The documentation](http://www.sphinx-doc.org/en/stable/config.html#confval-html_last_updated_fmt) describes `html_last_updated_fmt` as accepting a string in `strftime()` format:

> <dl><dt>html_last_updated_fmt</dt>
> <dd>If this is not None, a ‘Last updated on:’ timestamp is inserted at every page bottom, using the given <code>strftime()</code> format. The empty string is equivalent to <code>'%b %d, %Y'</code> (or a locale-dependent equivalent).</dd></dl>

`%-d` is a valid `strftime()` format on my system, which represents the day without leading zeros, i.e. `1` to `31`. However this is not parsed correctly by Sphinx:
```python
from datetime import datetime
from sphinx.util.i18n import format_date

now = datetime.now()

now.strftime('%d %-d')
# '04 4'

format_date('%d %-d', now, 'en')
# u'04 %-d'
```

Either the docs should be modified to indicate the actual list of acceptable formats, which seems to be contained in a dict [here](https://github.com/sphinx-doc/sphinx/blob/f746959bbd5127ee448b38efc0f014956beb0827/sphinx/util/i18n.py#L153), or the `format_date` function should be modified to accept all valid `strftime()` formats.


Discussion:

Patch:
diff --git a/CHANGES b/CHANGES
index c00d1a594..e494f9c0a 100644
--- a/CHANGES
+++ b/CHANGES
@@ -28,6 +28,7 @@ Bugs fixed
 * #4490: autodoc: type annotation is broken with python 3.7.0a4+
 * utils package is installed
 * #3952: apidoc: module header is too escaped
+* #4275: Formats accepted by sphinx.util.i18n.format_date are limited
 
 Testing
 --------
diff --git a/sphinx/util/i18n.py b/sphinx/util/i18n.py
index 75a8506fa..d52cc0aab 100644
--- a/sphinx/util/i18n.py
+++ b/sphinx/util/i18n.py
@@ -152,34 +152,45 @@ def find_catalog_source_files(locale_dirs, locale, domains=None, gettext_compact
 
 # date_format mappings: ustrftime() to bable.dates.format_datetime()
 date_format_mappings = {
-    '%a': 'EEE',     # Weekday as locale’s abbreviated name.
-    '%A': 'EEEE',    # Weekday as locale’s full name.
-    '%b': 'MMM',     # Month as locale’s abbreviated name.
-    '%B': 'MMMM',    # Month as locale’s full name.
-    '%c': 'medium',  # Locale’s appropriate date and time representation.
-    '%d': 'dd',      # Day of the month as a zero-padded decimal number.
-    '%H': 'HH',      # Hour (24-hour clock) as a decimal number [00,23].
-    '%I': 'hh',      # Hour (12-hour clock) as a decimal number [01,12].
-    '%j': 'DDD',     # Day of the year as a zero-padded decimal number.
-    '%m': 'MM',      # Month as a zero-padded decimal number.
-    '%M': 'mm',      # Minute as a decimal number [00,59].
-    '%p': 'a',       # Locale’s equivalent of either AM or PM.
-    '%S': 'ss',      # Second as a decimal number.
-    '%U': 'WW',      # Week number of the year (Sunday as the first day of the week)
-                     # as a zero padded decimal number. All days in a new year preceding
-                     # the first Sunday are considered to be in week 0.
-    '%w': 'e',       # Weekday as a decimal number, where 0 is Sunday and 6 is Saturday.
-    '%W': 'WW',      # Week number of the year (Monday as the first day of the week)
-                     # as a decimal number. All days in a new year preceding the first
-                     # Monday are considered to be in week 0.
-    '%x': 'medium',  # Locale’s appropriate date representation.
-    '%X': 'medium',  # Locale’s appropriate time representation.
-    '%y': 'YY',      # Year without century as a zero-padded decimal number.
-    '%Y': 'YYYY',    # Year with century as a decimal number.
-    '%Z': 'zzzz',    # Time zone name (no characters if no time zone exists).
-    '%%': '%',
+    '%a':  'EEE',     # Weekday as locale’s abbreviated name.
+    '%A':  'EEEE',    # Weekday as locale’s full name.
+    '%b':  'MMM',     # Month as locale’s abbreviated name.
+    '%B':  'MMMM',    # Month as locale’s full name.
+    '%c':  'medium',  # Locale’s appropriate date and time representation.
+    '%-d': 'd',       # Day of the month as a decimal number.
+    '%d':  'dd',      # Day of the month as a zero-padded decimal number.
+    '%-H': 'H',       # Hour (24-hour clock) as a decimal number [0,23].
+    '%H':  'HH',      # Hour (24-hour clock) as a zero-padded decimal number [00,23].
+    '%-I': 'h',       # Hour (12-hour clock) as a decimal number [1,12].
+    '%I':  'hh',      # Hour (12-hour clock) as a zero-padded decimal number [01,12].
+    '%-j': 'D',       # Day of the year as a decimal number.
+    '%j':  'DDD',     # Day of the year as a zero-padded decimal number.
+    '%-m': 'M',       # Month as a decimal number.
+    '%m':  'MM',      # Month as a zero-padded decimal number.
+    '%-M': 'm',       # Minute as a decimal number [0,59].
+    '%M':  'mm',      # Minute as a zero-padded decimal number [00,59].
+    '%p':  'a',       # Locale’s equivalent of either AM or PM.
+    '%-S': 's',       # Second as a decimal number.
+    '%S':  'ss',      # Second as a zero-padded decimal number.
+    '%U':  'WW',      # Week number of the year (Sunday as the first day of the week)
+                      # as a zero padded decimal number. All days in a new year preceding
+                      # the first Sunday are considered to be in week 0.
+    '%w':  'e',       # Weekday as a decimal number, where 0 is Sunday and 6 is Saturday.
+    '%-W': 'W',       # Week number of the year (Monday as the first day of the week)
+                      # as a decimal number. All days in a new year preceding the first
+                      # Monday are considered to be in week 0.
+    '%W':  'WW',      # Week number of the year (Monday as the first day of the week)
+                      # as a zero-padded decimal number.
+    '%x':  'medium',  # Locale’s appropriate date representation.
+    '%X':  'medium',  # Locale’s appropriate time representation.
+    '%y':  'YY',      # Year without century as a zero-padded decimal number.
+    '%Y':  'YYYY',    # Year with century as a decimal number.
+    '%Z':  'zzzz',    # Time zone name (no characters if no time zone exists).
+    '%%':  '%',
 }
 
+date_format_re = re.compile('(%s)' % '|'.join(date_format_mappings))
+
 
 def babel_format_date(date, format, locale, formatter=babel.dates.format_date):
     # type: (datetime, unicode, unicode, Callable) -> unicode
@@ -214,7 +225,7 @@ def format_date(format, date=None, language=None):
             date = datetime.now()
 
     result = []
-    tokens = re.split('(%.)', format)
+    tokens = date_format_re.split(format)
     for token in tokens:
         if token in date_format_mappings:
             babel_format = date_format_mappings.get(token, '')


Test patch:
diff --git a/tests/test_util_i18n.py b/tests/test_util_i18n.py
index bec4e91e9..14a7aae6f 100644
--- a/tests/test_util_i18n.py
+++ b/tests/test_util_i18n.py
@@ -176,6 +176,8 @@ def test_format_date():
     datet = datetime.datetime(2016, 2, 7, 5, 11, 17, 0)
     assert i18n.format_date(format, date=datet) == 'February 07, 2016, 05:11:17 05 AM'
 
+    format = '%B %-d, %Y, %-H:%-M:%-S %-I %p'
+    assert i18n.format_date(format, date=datet) == 'February 7, 2016, 5:11:17 5 AM'
     format = '%x'
     assert i18n.format_date(format, date=datet) == 'Feb 7, 2016'
     format = '%X'