Skip to content

Commit 7106044

Browse files
committed
gh-158252: Parse ordinal and week dates in C
Calculate dates for %j, %U/%W and %G/%V using the shared Gregorian calendar helpers. Preserve explicit weekdays and time.strptime yday values when a week or ordinal date crosses a year boundary.
1 parent 903b89c commit 7106044

4 files changed

Lines changed: 167 additions & 12 deletions

File tree

‎Include/internal/pycore_time.h‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ _PyTime_Weekday(int year, int yday)
340340
}
341341

342342

343-
// Locale-independent numeric strptime parsing.
343+
// Shared strptime parsing, with fallback for unsupported inputs.
344344
// A complete match returns 1; otherwise use Lib/_strptime.py. This function
345345
// neither allocates nor sets exceptions. The calendar date is validated, but
346346
// seconds may be 60 or 61: callers must apply their own time validation.

‎Lib/test/test_strptime.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,29 @@
1616

1717

1818
class PublicStrptimeTests(unittest.TestCase):
19+
def test_week_and_ordinal_dates(self):
20+
for year in (1, 4, 100, 400, 1900, 1999, 2000, 2004, 2020, 9999):
21+
for day in (1, 28, 59, 60, 61, 365, 366, 367):
22+
self.assert_matches_python(f'{year:04d} {day:03d}', '%Y %j')
23+
for week in (0, 1, 52, 53, 54):
24+
for weekday in range(1, 8):
25+
for fmt in ('%Y %U %u', '%Y %W %u', '%G %V %u'):
26+
self.assert_matches_python(
27+
f'{year:04d} {week:02d} {weekday}', fmt)
28+
for text, fmt in (
29+
('2023-02-31 060', '%Y-%m-%d %j'),
30+
('2024-12-31 001', '%Y-%m-%d %j'),
31+
('2023 366 1', '%Y %j %u'),
32+
('2024 53', '%Y %U'), ('366', '%j'),
33+
('2024 001 01 1', '%Y %j %W %u'),
34+
('2024 2020 01 1', '%Y %G %V %u'),
35+
('2024 001 01 1', '%G %j %V %u'),
36+
('2024 01 1', '%Y %V %u'), ('2024 01', '%G %V'),
37+
('2024', '%G'), ('0000 001', '%Y %j'),
38+
('2024 01 02 1', '%Y %U %W %u'),
39+
):
40+
self.assert_matches_python(text, fmt)
41+
1942
def test_numeric_aliases(self):
2043
for text, fmt in (
2144
('2024-02-29', '%F'), ('02/29/24', '%D'),
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Speed up parsing month and weekday names, 12-hour clocks, UTC offsets,
2+
numeric format aliases, and ordinal and week dates with :func:`time.strptime`,
3+
:meth:`datetime.date.strptime`, :meth:`datetime.time.strptime`, and
4+
:meth:`datetime.datetime.strptime`.

‎Python/_strptime.c‎

Lines changed: 139 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
* - %I with ASCII %p indicators, and the %k, %l, %P aliases;
1414
* - %z and %:z offsets with hours below 24, and terminal UTC/GMT %Z names;
1515
* - numeric aliases %F, %D, %T, %R, %e and whitespace %n/%t;
16+
* - ordinal dates (%j), calendar weeks (%U/%W), and ISO weeks (%G/%V);
1617
* - literal characters, %%, and ASCII whitespace.
1718
* Duplicate directives, mixed %Y/%y, and day-of-month without a year use the
18-
* fallback. So do other directives (including non-ASCII locale names and
19-
* week/day-of-year calculations).
19+
* fallback. So do non-ASCII locale names and directives not listed above.
2020
*/
2121

2222
static int
@@ -103,6 +103,48 @@ strptime_name(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos,
103103
#endif
104104
}
105105

106+
static int
107+
strptime_date_from_ordinal(int ordinal, _PyTime_StrptimeFields *fields)
108+
{
109+
if (ordinal < 1 || ordinal > _PyTime_DaysBeforeYear(10000)) {
110+
return 0;
111+
}
112+
int lower = 1, upper = 10000;
113+
while (lower + 1 < upper) {
114+
int middle = (lower + upper) / 2;
115+
if (_PyTime_DaysBeforeYear(middle) < ordinal) {
116+
lower = middle;
117+
}
118+
else {
119+
upper = middle;
120+
}
121+
}
122+
fields->year = lower;
123+
fields->yday = ordinal - _PyTime_DaysBeforeYear(lower);
124+
fields->day = fields->yday;
125+
int leap = lower % 4 == 0 && (lower % 100 != 0 || lower % 400 == 0);
126+
static const int month_days[] = {31, 28, 31, 30, 31, 30,
127+
31, 31, 30, 31, 30, 31};
128+
for (fields->month = 1; fields->month < 12; fields->month++) {
129+
int days = month_days[fields->month - 1] + (fields->month == 2 && leap);
130+
if (fields->day <= days) {
131+
break;
132+
}
133+
fields->day -= days;
134+
}
135+
if (fields->weekday < 0) {
136+
fields->weekday = (ordinal - 1) % 7;
137+
}
138+
return 1;
139+
}
140+
141+
static int
142+
strptime_iso_start(int year)
143+
{
144+
/* ISO week 1 contains January 4. */
145+
return _PyTime_DaysBeforeYear(year) + 4 - _PyTime_Weekday(year, 4);
146+
}
147+
106148
static int
107149
strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos,
108150
int require_colon, _PyTime_StrptimeFields *fields)
@@ -159,7 +201,7 @@ strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos,
159201
return (int)(pos - start);
160202
}
161203

162-
/* Return 1 for a complete numeric match, or 0 if fallback is required.
204+
/* Return 1 for a complete match, or 0 if fallback is required.
163205
*
164206
* Parsing doesn't allocate or set exceptions. Some mismatches require regex
165207
* backtracking, but even inputs known to be invalid use the Python parser:
@@ -184,6 +226,10 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
184226
WEEKDAY = 1U << 9,
185227
AMPM = 1U << 10,
186228
ZONE = 1U << 11,
229+
JULIAN = 1U << 12,
230+
WEEK = 1U << 13,
231+
ISO_YEAR = 1U << 14,
232+
ISO_WEEK = 1U << 15,
187233
ANY_YEAR = YEAR | SHORT_YEAR,
188234
};
189235
static const unsigned short directive_bits[128] = {
@@ -199,6 +245,10 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
199245
['w'] = WEEKDAY,
200246
['p'] = AMPM,
201247
['Z'] = ZONE,
248+
['j'] = JULIAN,
249+
['U'] = WEEK,
250+
['G'] = ISO_YEAR,
251+
['V'] = ISO_WEEK,
202252
};
203253
/* String subclasses and non-ASCII input retain Python's behavior. */
204254
if (!PyUnicode_CheckExact(string) || !PyUnicode_CheckExact(format) ||
@@ -216,6 +266,9 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
216266
unsigned int seen = 0;
217267
int hour12 = 0;
218268
int ampm = -1;
269+
int julian = -1, week = -1, monday_first = 0;
270+
int have_julian = 0;
271+
int iso_year = -1, iso_week = -1;
219272
Py_ssize_t pos = 0;
220273

221274
for (Py_ssize_t i = 0; ; i++) {
@@ -315,6 +368,9 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
315368
else if (c == 'I') {
316369
field = 'H';
317370
}
371+
else if (c == 'W') {
372+
field = 'U';
373+
}
318374
unsigned int bit = directive_bits[field];
319375
if (bit == 0) {
320376
return 0;
@@ -372,7 +428,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
372428

373429
int minimum = 1;
374430
int maximum = 2;
375-
if (c == 'Y') {
431+
if (c == 'Y' || c == 'G') {
376432
minimum = maximum = 4;
377433
}
378434
else if (c == 'y') {
@@ -381,6 +437,9 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
381437
else if (c == 'f') {
382438
maximum = 6;
383439
}
440+
else if (c == 'j') {
441+
maximum = 3;
442+
}
384443
else if (field == 'w') {
385444
maximum = 1;
386445
}
@@ -396,6 +455,30 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
396455
}
397456
pos += count;
398457
switch (c) {
458+
case 'j':
459+
if (value < 1 || value > 366) {
460+
return 0;
461+
}
462+
julian = value;
463+
have_julian = 1;
464+
break;
465+
case 'U':
466+
case 'W':
467+
if (value > 53) {
468+
return 0;
469+
}
470+
week = value;
471+
monday_first = c == 'W';
472+
break;
473+
case 'G':
474+
iso_year = value;
475+
break;
476+
case 'V':
477+
if (value < 1 || value > 53) {
478+
return 0;
479+
}
480+
iso_week = value;
481+
break;
399482
case 'I':
400483
if (value < 1 || value > 12) {
401484
return 0;
@@ -465,6 +548,53 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
465548
}
466549
fields->hour = fields->hour % 12 + (ampm == 1 ? 12 : 0);
467550
}
551+
/* Mixed fields and incomplete ISO dates retain Python's diagnostics. */
552+
if (pos != length || (seen & ANY_YEAR) == ANY_YEAR ||
553+
((seen & DAY) && !(seen & ANY_YEAR) && iso_year < 0)) {
554+
return 0;
555+
}
556+
if (iso_year >= 0 || iso_week >= 0) {
557+
if (iso_year < 1 || iso_week < 1 || fields->weekday < 0 ||
558+
julian >= 0 || week >= 0) {
559+
return 0;
560+
}
561+
int monday = strptime_iso_start(iso_year) + 7 * (iso_week - 1);
562+
if (monday >= strptime_iso_start(iso_year + 1) ||
563+
!strptime_date_from_ordinal(monday + fields->weekday, fields)) {
564+
return 0;
565+
}
566+
}
567+
if (fields->year < 1) {
568+
return 0;
569+
}
570+
if (julian < 0 && week >= 0 && fields->weekday >= 0) {
571+
int first = _PyTime_Weekday(fields->year, 1);
572+
int weekday = fields->weekday;
573+
if (!monday_first) {
574+
first = (first + 1) % 7;
575+
weekday = (weekday + 1) % 7;
576+
}
577+
julian = week == 0 ? 1 + weekday - first
578+
: 1 + (7 - first) % 7 + 7 * (week - 1) + weekday;
579+
have_julian = 1;
580+
}
581+
if (have_julian) {
582+
/* Week zero can fall in the previous year. A day spilling into the
583+
* next year retains its original yday in time.strptime's result.
584+
*/
585+
int year = fields->year;
586+
if (julian <= 0) {
587+
if (year == 1) {
588+
return 0;
589+
}
590+
year--;
591+
julian += _PyTime_DaysBeforeYear(year + 1) - _PyTime_DaysBeforeYear(year);
592+
}
593+
if (!strptime_date_from_ordinal(_PyTime_DaysBeforeYear(year) + julian, fields)) {
594+
return 0;
595+
}
596+
fields->yday = julian;
597+
}
468598
static const int days_before_month[] = {
469599
0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365
470600
};
@@ -475,15 +605,13 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
475605
if (fields->month == 2) {
476606
month_days += leap;
477607
}
478-
/* Mixed %Y/%y and day-without-year diagnostics belong to Python. */
479-
if (pos != length || (seen & ANY_YEAR) == ANY_YEAR ||
480-
((seen & DAY) && !(seen & ANY_YEAR)) ||
481-
fields->year < 1 ||
482-
fields->day > month_days) {
608+
if (fields->day > month_days) {
483609
return 0;
484610
}
485-
fields->yday = days_before_month[fields->month] + fields->day +
486-
(fields->month > 2 && leap);
611+
if (!have_julian) {
612+
fields->yday = days_before_month[fields->month] + fields->day +
613+
(fields->month > 2 && leap);
614+
}
487615
if (fields->weekday < 0) {
488616
fields->weekday = _PyTime_Weekday(fields->year, fields->yday);
489617
}

0 commit comments

Comments
 (0)