Skip to content

Commit 4a250d0

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 efae1db commit 4a250d0

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++) {
@@ -314,6 +367,9 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
314367
else if (c == 'I') {
315368
field = 'H';
316369
}
370+
else if (c == 'W') {
371+
field = 'U';
372+
}
317373
unsigned int bit = directive_bits[field];
318374
if (bit == 0) {
319375
return 0;
@@ -371,7 +427,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
371427

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

0 commit comments

Comments
 (0)