Skip to content
This repository was archived by the owner on Jul 17, 2024. It is now read-only.

Commit 9c7726f

Browse files
feat: Add strptime and strftime to datetime classes
- strftime and strptime easily map to DateTimeFormatterBuilder, although with a different syntax. - strftime and strptime are implementation dependent, yielding different results on different operating systems and locale definitions. - The JVM locale is set to the Python's locale on startup
1 parent e6bb0f7 commit 9c7726f

File tree

9 files changed

+357
-5
lines changed

9 files changed

+357
-5
lines changed

jpyinterpreter/src/main/java/ai/timefold/jpyinterpreter/types/datetime/PythonDate.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ private static void registerMethods() throws NoSuchMethodException {
108108
DATE_TYPE.addMethod("isoformat",
109109
PythonDate.class.getMethod("iso_format"));
110110

111+
DATE_TYPE.addMethod("strftime",
112+
ArgumentSpec.forFunctionReturning("strftime", PythonString.class.getName())
113+
.addArgument("format", PythonString.class.getName())
114+
.asPythonFunctionSignature(PythonDate.class.getMethod("strftime", PythonString.class)));
115+
111116
DATE_TYPE.addMethod("ctime",
112117
PythonDate.class.getMethod("ctime"));
113118

@@ -363,8 +368,8 @@ public PythonString ctime() {
363368
}
364369

365370
public PythonString strftime(PythonString format) {
366-
// TODO
367-
throw new UnsupportedOperationException();
371+
var formatter = PythonDateTimeFormatter.getDateTimeFormatter(format.value);
372+
return PythonString.valueOf(formatter.format(localDate));
368373
}
369374

370375
@Override

jpyinterpreter/src/main/java/ai/timefold/jpyinterpreter/types/datetime/PythonDateTime.java

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package ai.timefold.jpyinterpreter.types.datetime;
22

33
import java.time.Clock;
4+
import java.time.DateTimeException;
45
import java.time.Duration;
56
import java.time.Instant;
67
import java.time.LocalDate;
@@ -10,8 +11,10 @@
1011
import java.time.ZoneId;
1112
import java.time.ZoneOffset;
1213
import java.time.ZonedDateTime;
14+
import java.time.format.DateTimeFormatter;
1315
import java.time.format.TextStyle;
1416
import java.time.temporal.Temporal;
17+
import java.time.temporal.TemporalQuery;
1518
import java.util.List;
1619
import java.util.Locale;
1720
import java.util.regex.Matcher;
@@ -120,6 +123,16 @@ private static void registerMethods() throws NoSuchMethodException {
120123
PythonNumber.class,
121124
PythonLikeObject.class)));
122125

126+
DATE_TIME_TYPE.addMethod("strptime",
127+
ArgumentSpec.forFunctionReturning("strptime", PythonDateTime.class.getName())
128+
.addArgument("datetime_type", PythonLikeType.class.getName())
129+
.addArgument("date_string", PythonString.class.getName())
130+
.addArgument("format", PythonString.class.getName())
131+
.asClassPythonFunctionSignature(PythonDateTime.class.getMethod("strptime",
132+
PythonLikeType.class,
133+
PythonString.class,
134+
PythonString.class)));
135+
123136
DATE_TIME_TYPE.addMethod("utcfromtimestamp",
124137
ArgumentSpec.forFunctionReturning("utcfromtimestamp", PythonDate.class.getName())
125138
.addArgument("date_type", PythonLikeType.class.getName())
@@ -203,6 +216,11 @@ private static void registerMethods() throws NoSuchMethodException {
203216
.asPythonFunctionSignature(
204217
PythonDateTime.class.getMethod("iso_format", PythonString.class, PythonString.class)));
205218

219+
DATE_TIME_TYPE.addMethod("strftime",
220+
ArgumentSpec.forFunctionReturning("strftime", PythonString.class.getName())
221+
.addArgument("format", PythonString.class.getName())
222+
.asPythonFunctionSignature(PythonDateTime.class.getMethod("strftime", PythonString.class)));
223+
206224
DATE_TIME_TYPE.addMethod("ctime",
207225
PythonDateTime.class.getMethod("ctime"));
208226

@@ -506,6 +524,38 @@ public static PythonDate from_iso_calendar(PythonInteger year, PythonInteger wee
506524
}
507525
}
508526

527+
private static <T> T tryParseOrNull(DateTimeFormatter formatter, String text, TemporalQuery<T> query) {
528+
try {
529+
return formatter.parse(text, query);
530+
} catch (DateTimeException e) {
531+
return null;
532+
}
533+
}
534+
535+
public static PythonDateTime strptime(PythonLikeType type, PythonString date_string, PythonString format) {
536+
if (type != DATE_TIME_TYPE) {
537+
throw new TypeError("Unknown datetime type (" + type + ").");
538+
}
539+
var formatter = PythonDateTimeFormatter.getDateTimeFormatter(format.value);
540+
var asZonedDateTime = tryParseOrNull(formatter, date_string.value, ZonedDateTime::from);
541+
if (asZonedDateTime != null) {
542+
return new PythonDateTime(asZonedDateTime);
543+
}
544+
var asLocalDateTime = tryParseOrNull(formatter, date_string.value, LocalDateTime::from);
545+
if (asLocalDateTime != null) {
546+
return new PythonDateTime(asLocalDateTime);
547+
}
548+
var asLocalDate = tryParseOrNull(formatter, date_string.value, LocalDate::from);
549+
if (asLocalDate != null) {
550+
return new PythonDateTime(asLocalDate.atTime(LocalTime.MIDNIGHT));
551+
}
552+
var asLocalTime = tryParseOrNull(formatter, date_string.value, LocalTime::from);
553+
if (asLocalTime != null) {
554+
return new PythonDateTime(asLocalTime.atDate(LocalDate.of(1900, 1, 1)));
555+
}
556+
throw new ValueError("data " + date_string.repr() + " does not match the format " + format.repr());
557+
}
558+
509559
public PythonDateTime add_time_delta(PythonTimeDelta summand) {
510560
if (dateTime instanceof LocalDateTime) {
511561
return new PythonDateTime(((LocalDateTime) dateTime).plus(summand.duration));
@@ -699,8 +749,8 @@ public PythonString ctime() {
699749

700750
@Override
701751
public PythonString strftime(PythonString format) {
702-
// TODO
703-
throw new UnsupportedOperationException();
752+
var formatter = PythonDateTimeFormatter.getDateTimeFormatter(format.value);
753+
return PythonString.valueOf(formatter.format(dateTime));
704754
}
705755

706756
@Override
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package ai.timefold.jpyinterpreter.types.datetime;
2+
3+
import java.time.DayOfWeek;
4+
import java.time.format.DateTimeFormatter;
5+
import java.time.format.DateTimeFormatterBuilder;
6+
import java.time.format.FormatStyle;
7+
import java.time.format.TextStyle;
8+
import java.time.temporal.ChronoField;
9+
import java.time.temporal.WeekFields;
10+
import java.util.regex.Pattern;
11+
12+
import ai.timefold.jpyinterpreter.types.errors.ValueError;
13+
14+
/**
15+
* Based on the format specified
16+
* <a href="https://docs.python.org/3.11/library/datetime.html#strftime-and-strptime-format-codes">in
17+
* the datetime documentation</a>.
18+
*/
19+
public class PythonDateTimeFormatter {
20+
private final static Pattern DIRECTIVE_PATTERN = Pattern.compile("([^%]*)%(.)");
21+
22+
static DateTimeFormatter getDateTimeFormatter(String pattern) {
23+
DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder();
24+
var matcher = DIRECTIVE_PATTERN.matcher(pattern);
25+
int endIndex = 0;
26+
while (matcher.find()) {
27+
var literalPart = matcher.group(1);
28+
builder.appendLiteral(literalPart);
29+
endIndex = matcher.end();
30+
31+
char directive = matcher.group(2).charAt(0);
32+
switch (directive) {
33+
case 'a' -> {
34+
builder.appendText(ChronoField.DAY_OF_WEEK, TextStyle.SHORT);
35+
}
36+
case 'A' -> {
37+
builder.appendText(ChronoField.DAY_OF_WEEK, TextStyle.FULL);
38+
}
39+
case 'w' -> {
40+
builder.appendValue(ChronoField.DAY_OF_WEEK);
41+
}
42+
case 'd' -> {
43+
builder.appendValue(ChronoField.DAY_OF_MONTH, 2);
44+
}
45+
case 'b' -> {
46+
builder.appendText(ChronoField.MONTH_OF_YEAR, TextStyle.SHORT);
47+
}
48+
case 'B' -> {
49+
builder.appendText(ChronoField.MONTH_OF_YEAR, TextStyle.FULL);
50+
}
51+
case 'm' -> {
52+
builder.appendValue(ChronoField.MONTH_OF_YEAR, 2);
53+
}
54+
case 'y' -> {
55+
builder.appendPattern("uu");
56+
}
57+
case 'Y' -> {
58+
builder.appendValue(ChronoField.YEAR);
59+
}
60+
case 'H' -> {
61+
builder.appendValue(ChronoField.HOUR_OF_DAY, 2);
62+
}
63+
case 'I' -> {
64+
builder.appendValue(ChronoField.HOUR_OF_AMPM, 2);
65+
}
66+
case 'p' -> {
67+
builder.appendText(ChronoField.AMPM_OF_DAY);
68+
}
69+
case 'M' -> {
70+
builder.appendValue(ChronoField.MINUTE_OF_HOUR, 2);
71+
}
72+
case 'S' -> {
73+
builder.appendValue(ChronoField.SECOND_OF_MINUTE, 2);
74+
}
75+
case 'f' -> {
76+
builder.appendValue(ChronoField.MICRO_OF_SECOND, 6);
77+
}
78+
case 'z' -> {
79+
builder.appendOffset("+HHmmss", "");
80+
}
81+
case 'Z' -> {
82+
builder.appendZoneOrOffsetId();
83+
}
84+
case 'j' -> {
85+
builder.appendValue(ChronoField.DAY_OF_YEAR, 3);
86+
}
87+
case 'U' -> {
88+
builder.appendValue(WeekFields.of(DayOfWeek.SUNDAY, 7).weekOfYear(), 2);
89+
}
90+
case 'W' -> {
91+
builder.appendValue(WeekFields.of(DayOfWeek.MONDAY, 7).weekOfYear(), 2);
92+
}
93+
case 'c' -> {
94+
builder.appendLocalized(FormatStyle.MEDIUM, FormatStyle.MEDIUM);
95+
}
96+
case 'x' -> {
97+
builder.appendLocalized(FormatStyle.MEDIUM, null);
98+
}
99+
case 'X' -> {
100+
builder.appendLocalized(null, FormatStyle.MEDIUM);
101+
}
102+
case '%' -> {
103+
builder.appendLiteral("%");
104+
}
105+
case 'G' -> {
106+
builder.appendValue(WeekFields.of(DayOfWeek.MONDAY, 4).weekBasedYear());
107+
}
108+
case 'u' -> {
109+
builder.appendValue(WeekFields.of(DayOfWeek.MONDAY, 4).dayOfWeek(), 1);
110+
}
111+
case 'V' -> {
112+
builder.appendValue(WeekFields.of(DayOfWeek.MONDAY, 4).weekOfYear(), 2);
113+
}
114+
default -> {
115+
throw new ValueError("Invalid directive (" + directive + ") in format string (" + pattern + ").");
116+
}
117+
}
118+
}
119+
builder.appendLiteral(pattern.substring(endIndex));
120+
return builder.toFormatter();
121+
}
122+
}

jpyinterpreter/src/main/java/ai/timefold/jpyinterpreter/types/datetime/PythonTime.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ private static void registerMethods() throws NoSuchMethodException {
9090
.addArgument("timespec", PythonString.class.getName(), PythonString.valueOf("auto"))
9191
.asPythonFunctionSignature(PythonTime.class.getMethod("isoformat", PythonString.class)));
9292

93+
TIME_TYPE.addMethod("strftime",
94+
ArgumentSpec.forFunctionReturning("strftime", PythonString.class.getName())
95+
.addArgument("format", PythonString.class.getName())
96+
.asPythonFunctionSignature(PythonTime.class.getMethod("strftime", PythonString.class)));
97+
9398
TIME_TYPE.addMethod("tzname",
9499
PythonTime.class.getMethod("tzname"));
95100

@@ -328,6 +333,11 @@ public PythonString isoformat(PythonString formatSpec) {
328333
return PythonString.valueOf(result);
329334
}
330335

336+
public PythonString strftime(PythonString formatSpec) {
337+
var formatter = PythonDateTimeFormatter.getDateTimeFormatter(formatSpec.value);
338+
return PythonString.valueOf(formatter.format(localTime));
339+
}
340+
331341
@Override
332342
public PythonString $method$__str__() {
333343
return PythonString.valueOf(toString());

jpyinterpreter/src/main/python/jvm_setup.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import jpype.imports
44
import importlib.resources
55
import os
6+
import locale
67
from typing import List, ContextManager
78

89

@@ -52,7 +53,15 @@ def init(*args, path: List[str] = None, include_translator_jars: bool = True,
5253
path = []
5354
if include_translator_jars:
5455
path = path + extract_python_translator_jars()
55-
jpype.startJVM(*args, classpath=path, convertStrings=True) # noqa
56+
57+
locale_and_country = locale.getlocale()[0]
58+
extra_jvm_args = []
59+
if locale_and_country is not None:
60+
lang, country = locale_and_country.rsplit('_', maxsplit=1)
61+
extra_jvm_args.append(f'-Duser.language={lang}')
62+
extra_jvm_args.append(f'-Duser.country={country}')
63+
64+
jpype.startJVM(*args, *extra_jvm_args, classpath=path, convertStrings=True) # noqa
5665

5766
if class_output_path is not None:
5867
from ai.timefold.jpyinterpreter import InterpreterStartupOptions # noqa

jpyinterpreter/tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22
from typing import Callable, Any
33
from copy import deepcopy
4+
import locale
45

56

67
def get_argument_cloner(clone_arguments):
@@ -203,6 +204,7 @@ def pytest_sessionstart(session):
203204
import pathlib
204205
import sys
205206

207+
locale.setlocale(locale.LC_ALL, 'en_US')
206208
class_output_path = None
207209
if session.config.getoption('--output-generated-classes') != 'false':
208210
class_output_path = pathlib.Path('target', 'tox-generated-classes', 'python',

jpyinterpreter/tests/datetime/test_date.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,3 +312,49 @@ def function(x: date) -> str:
312312
verifier = verifier_for(function)
313313

314314
verifier.verify(date(2002, 12, 4), expected_result='Wed Dec 4 00:00:00 2002')
315+
316+
317+
def test_strftime():
318+
def function(x: date, fmt: str) -> str:
319+
return x.strftime(fmt)
320+
321+
verifier = verifier_for(function)
322+
323+
verifier.verify(date(1, 2, 3), '%a',
324+
expected_result='Sat')
325+
verifier.verify(date(1, 2, 3), '%A',
326+
expected_result='Saturday')
327+
verifier.verify(date(1, 2, 3), '%W',
328+
expected_result='05')
329+
verifier.verify(date(1, 2, 3), '%d',
330+
expected_result='03')
331+
verifier.verify(date(1, 2, 3), '%b',
332+
expected_result='Feb')
333+
verifier.verify(date(1, 2, 3), '%B',
334+
expected_result='February')
335+
verifier.verify(date(1, 2, 3), '%m',
336+
expected_result='02')
337+
verifier.verify(date(1, 2, 3), '%y',
338+
expected_result='01')
339+
verifier.verify(date(1001, 2, 3), '%y',
340+
expected_result='01')
341+
# %Y have different results depending on the platform;
342+
# Windows 0-pad it, Linux does not.
343+
# verifier.verify(date(1, 2, 3), '%Y',
344+
# expected_result='1')
345+
verifier.verify(date(1, 2, 3), '%j',
346+
expected_result='034')
347+
verifier.verify(date(1, 2, 3), '%U',
348+
expected_result='04')
349+
verifier.verify(date(1, 2, 3), '%W',
350+
expected_result='05')
351+
# %Y have different results depending on the platform;
352+
# Windows 0-pad it, Linux does not.
353+
# verifier.verify(date(1, 2, 3), '%G',
354+
# expected_result='1')
355+
verifier.verify(date(1, 2, 3), '%u',
356+
expected_result='6')
357+
verifier.verify(date(1, 2, 3), '%%',
358+
expected_result='%')
359+
verifier.verify(date(1, 2, 3), '%V',
360+
expected_result='05')

0 commit comments

Comments
 (0)