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

Commit

Permalink
feat: Add strptime and strftime to datetime classes (#101)
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
Christopher-Chianelli authored Jul 2, 2024
1 parent e6bb0f7 commit 81bbd40
Show file tree
Hide file tree
Showing 9 changed files with 370 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ private static void registerMethods() throws NoSuchMethodException {
DATE_TYPE.addMethod("isoformat",
PythonDate.class.getMethod("iso_format"));

DATE_TYPE.addMethod("strftime",
ArgumentSpec.forFunctionReturning("strftime", PythonString.class.getName())
.addArgument("format", PythonString.class.getName())
.asPythonFunctionSignature(PythonDate.class.getMethod("strftime", PythonString.class)));

DATE_TYPE.addMethod("ctime",
PythonDate.class.getMethod("ctime"));

Expand Down Expand Up @@ -363,8 +368,8 @@ public PythonString ctime() {
}

public PythonString strftime(PythonString format) {
// TODO
throw new UnsupportedOperationException();
var formatter = PythonDateTimeFormatter.getDateTimeFormatter(format.value);
return PythonString.valueOf(formatter.format(localDate));
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ai.timefold.jpyinterpreter.types.datetime;

import java.time.Clock;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
Expand All @@ -10,8 +11,10 @@
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.TextStyle;
import java.time.temporal.Temporal;
import java.time.temporal.TemporalQuery;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
Expand Down Expand Up @@ -120,6 +123,16 @@ private static void registerMethods() throws NoSuchMethodException {
PythonNumber.class,
PythonLikeObject.class)));

DATE_TIME_TYPE.addMethod("strptime",
ArgumentSpec.forFunctionReturning("strptime", PythonDateTime.class.getName())
.addArgument("datetime_type", PythonLikeType.class.getName())
.addArgument("date_string", PythonString.class.getName())
.addArgument("format", PythonString.class.getName())
.asClassPythonFunctionSignature(PythonDateTime.class.getMethod("strptime",
PythonLikeType.class,
PythonString.class,
PythonString.class)));

DATE_TIME_TYPE.addMethod("utcfromtimestamp",
ArgumentSpec.forFunctionReturning("utcfromtimestamp", PythonDate.class.getName())
.addArgument("date_type", PythonLikeType.class.getName())
Expand Down Expand Up @@ -203,6 +216,11 @@ private static void registerMethods() throws NoSuchMethodException {
.asPythonFunctionSignature(
PythonDateTime.class.getMethod("iso_format", PythonString.class, PythonString.class)));

DATE_TIME_TYPE.addMethod("strftime",
ArgumentSpec.forFunctionReturning("strftime", PythonString.class.getName())
.addArgument("format", PythonString.class.getName())
.asPythonFunctionSignature(PythonDateTime.class.getMethod("strftime", PythonString.class)));

DATE_TIME_TYPE.addMethod("ctime",
PythonDateTime.class.getMethod("ctime"));

Expand Down Expand Up @@ -506,6 +524,38 @@ public static PythonDate from_iso_calendar(PythonInteger year, PythonInteger wee
}
}

private static <T> T tryParseOrNull(DateTimeFormatter formatter, String text, TemporalQuery<T> query) {
try {
return formatter.parse(text, query);
} catch (DateTimeException e) {
return null;
}
}

public static PythonDateTime strptime(PythonLikeType type, PythonString date_string, PythonString format) {
if (type != DATE_TIME_TYPE) {
throw new TypeError("Unknown datetime type (" + type + ").");
}
var formatter = PythonDateTimeFormatter.getDateTimeFormatter(format.value);
var asZonedDateTime = tryParseOrNull(formatter, date_string.value, ZonedDateTime::from);
if (asZonedDateTime != null) {
return new PythonDateTime(asZonedDateTime);
}
var asLocalDateTime = tryParseOrNull(formatter, date_string.value, LocalDateTime::from);
if (asLocalDateTime != null) {
return new PythonDateTime(asLocalDateTime);
}
var asLocalDate = tryParseOrNull(formatter, date_string.value, LocalDate::from);
if (asLocalDate != null) {
return new PythonDateTime(asLocalDate.atTime(LocalTime.MIDNIGHT));
}
var asLocalTime = tryParseOrNull(formatter, date_string.value, LocalTime::from);
if (asLocalTime != null) {
return new PythonDateTime(asLocalTime.atDate(LocalDate.of(1900, 1, 1)));
}
throw new ValueError("data " + date_string.repr() + " does not match the format " + format.repr());
}

public PythonDateTime add_time_delta(PythonTimeDelta summand) {
if (dateTime instanceof LocalDateTime) {
return new PythonDateTime(((LocalDateTime) dateTime).plus(summand.duration));
Expand Down Expand Up @@ -699,8 +749,8 @@ public PythonString ctime() {

@Override
public PythonString strftime(PythonString format) {
// TODO
throw new UnsupportedOperationException();
var formatter = PythonDateTimeFormatter.getDateTimeFormatter(format.value);
return PythonString.valueOf(formatter.format(dateTime));
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package ai.timefold.jpyinterpreter.types.datetime;

import java.time.DayOfWeek;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.FormatStyle;
import java.time.format.TextStyle;
import java.time.temporal.ChronoField;
import java.time.temporal.WeekFields;
import java.util.regex.Pattern;

import ai.timefold.jpyinterpreter.types.errors.ValueError;

/**
* Based on the format specified
* <a href="https://docs.python.org/3.11/library/datetime.html#strftime-and-strptime-format-codes">in
* the datetime documentation</a>.
*/
public class PythonDateTimeFormatter {
private final static Pattern DIRECTIVE_PATTERN = Pattern.compile("([^%]*)%(.)");

static DateTimeFormatter getDateTimeFormatter(String pattern) {
DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder();
var matcher = DIRECTIVE_PATTERN.matcher(pattern);
int endIndex = 0;
while (matcher.find()) {
var literalPart = matcher.group(1);
builder.appendLiteral(literalPart);
endIndex = matcher.end();

char directive = matcher.group(2).charAt(0);
switch (directive) {
case 'a' -> {
builder.appendText(ChronoField.DAY_OF_WEEK, TextStyle.SHORT);
}
case 'A' -> {
builder.appendText(ChronoField.DAY_OF_WEEK, TextStyle.FULL);
}
case 'w' -> {
builder.appendValue(ChronoField.DAY_OF_WEEK);
}
case 'd' -> {
builder.appendValue(ChronoField.DAY_OF_MONTH, 2);
}
case 'b' -> {
builder.appendText(ChronoField.MONTH_OF_YEAR, TextStyle.SHORT);
}
case 'B' -> {
builder.appendText(ChronoField.MONTH_OF_YEAR, TextStyle.FULL);
}
case 'm' -> {
builder.appendValue(ChronoField.MONTH_OF_YEAR, 2);
}
case 'y' -> {
builder.appendPattern("uu");
}
case 'Y' -> {
builder.appendValue(ChronoField.YEAR);
}
case 'H' -> {
builder.appendValue(ChronoField.HOUR_OF_DAY, 2);
}
case 'I' -> {
builder.appendValue(ChronoField.HOUR_OF_AMPM, 2);
}
case 'p' -> {
builder.appendText(ChronoField.AMPM_OF_DAY);
}
case 'M' -> {
builder.appendValue(ChronoField.MINUTE_OF_HOUR, 2);
}
case 'S' -> {
builder.appendValue(ChronoField.SECOND_OF_MINUTE, 2);
}
case 'f' -> {
builder.appendValue(ChronoField.MICRO_OF_SECOND, 6);
}
case 'z' -> {
builder.appendOffset("+HHmmss", "");
}
case 'Z' -> {
builder.appendZoneOrOffsetId();
}
case 'j' -> {
builder.appendValue(ChronoField.DAY_OF_YEAR, 3);
}
case 'U' -> {
builder.appendValue(WeekFields.of(DayOfWeek.SUNDAY, 7).weekOfYear(), 2);
}
case 'W' -> {
builder.appendValue(WeekFields.of(DayOfWeek.MONDAY, 7).weekOfYear(), 2);
}
case 'c' -> {
builder.appendLocalized(FormatStyle.MEDIUM, FormatStyle.MEDIUM);
}
case 'x' -> {
builder.appendLocalized(FormatStyle.MEDIUM, null);
}
case 'X' -> {
builder.appendLocalized(null, FormatStyle.MEDIUM);
}
case '%' -> {
builder.appendLiteral("%");
}
case 'G' -> {
builder.appendValue(WeekFields.of(DayOfWeek.MONDAY, 4).weekBasedYear());
}
case 'u' -> {
builder.appendValue(WeekFields.of(DayOfWeek.MONDAY, 4).dayOfWeek(), 1);
}
case 'V' -> {
builder.appendValue(WeekFields.of(DayOfWeek.MONDAY, 4).weekOfYear(), 2);
}
default -> {
throw new ValueError("Invalid directive (" + directive + ") in format string (" + pattern + ").");
}
}
}
builder.appendLiteral(pattern.substring(endIndex));
return builder.toFormatter();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ private static void registerMethods() throws NoSuchMethodException {
.addArgument("timespec", PythonString.class.getName(), PythonString.valueOf("auto"))
.asPythonFunctionSignature(PythonTime.class.getMethod("isoformat", PythonString.class)));

TIME_TYPE.addMethod("strftime",
ArgumentSpec.forFunctionReturning("strftime", PythonString.class.getName())
.addArgument("format", PythonString.class.getName())
.asPythonFunctionSignature(PythonTime.class.getMethod("strftime", PythonString.class)));

TIME_TYPE.addMethod("tzname",
PythonTime.class.getMethod("tzname"));

Expand Down Expand Up @@ -328,6 +333,11 @@ public PythonString isoformat(PythonString formatSpec) {
return PythonString.valueOf(result);
}

public PythonString strftime(PythonString formatSpec) {
var formatter = PythonDateTimeFormatter.getDateTimeFormatter(formatSpec.value);
return PythonString.valueOf(formatter.format(localTime));
}

@Override
public PythonString $method$__str__() {
return PythonString.valueOf(toString());
Expand Down
20 changes: 19 additions & 1 deletion jpyinterpreter/src/main/python/jvm_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import jpype.imports
import importlib.resources
import os
import locale
from typing import List, ContextManager


Expand Down Expand Up @@ -52,7 +53,24 @@ def init(*args, path: List[str] = None, include_translator_jars: bool = True,
path = []
if include_translator_jars:
path = path + extract_python_translator_jars()
jpype.startJVM(*args, classpath=path, convertStrings=True) # noqa

user_locale = locale.getlocale()[0]
extra_jvm_args = []
if user_locale is not None:
user_locale = locale.normalize(user_locale)
if '.' in user_locale:
user_locale, _ = user_locale.split('.', 1)
if '_' in user_locale:
lang, country = user_locale.rsplit('_', maxsplit=1)
extra_jvm_args.append(f'-Duser.language={lang}')
extra_jvm_args.append(f'-Duser.country={country}')
else:
extra_jvm_args.append(f'-Duser.language={user_locale}')
else:
# C Locale
extra_jvm_args.append(f'-Duser.language=C')

jpype.startJVM(*args, *extra_jvm_args, classpath=path, convertStrings=True) # noqa

if class_output_path is not None:
from ai.timefold.jpyinterpreter import InterpreterStartupOptions # noqa
Expand Down
2 changes: 2 additions & 0 deletions jpyinterpreter/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pytest
from typing import Callable, Any
from copy import deepcopy
import locale


def get_argument_cloner(clone_arguments):
Expand Down Expand Up @@ -203,6 +204,7 @@ def pytest_sessionstart(session):
import pathlib
import sys

locale.setlocale(locale.LC_ALL, 'C')
class_output_path = None
if session.config.getoption('--output-generated-classes') != 'false':
class_output_path = pathlib.Path('target', 'tox-generated-classes', 'python',
Expand Down
48 changes: 48 additions & 0 deletions jpyinterpreter/tests/datetime/test_date.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,51 @@ def function(x: date) -> str:
verifier = verifier_for(function)

verifier.verify(date(2002, 12, 4), expected_result='Wed Dec 4 00:00:00 2002')


def test_strftime():
def function(x: date, fmt: str) -> str:
return x.strftime(fmt)

verifier = verifier_for(function)

verifier.verify(date(1, 2, 3), '%a',
expected_result='Sat')
# Java C Locale uses the short form for the full variant of week days
# verifier.verify(date(1, 2, 3), '%A',
# expected_result='Saturday')
verifier.verify(date(1, 2, 3), '%W',
expected_result='05')
verifier.verify(date(1, 2, 3), '%d',
expected_result='03')
verifier.verify(date(1, 2, 3), '%b',
expected_result='Feb')
# Java C Locale uses the short form for the full variant of months
# verifier.verify(date(1, 2, 3), '%B',
# expected_result='February')
verifier.verify(date(1, 2, 3), '%m',
expected_result='02')
verifier.verify(date(1, 2, 3), '%y',
expected_result='01')
verifier.verify(date(1001, 2, 3), '%y',
expected_result='01')
# %Y have different results depending on the platform;
# Windows 0-pad it, Linux does not.
# verifier.verify(date(1, 2, 3), '%Y',
# expected_result='1')
verifier.verify(date(1, 2, 3), '%j',
expected_result='034')
verifier.verify(date(1, 2, 3), '%U',
expected_result='04')
verifier.verify(date(1, 2, 3), '%W',
expected_result='05')
# %Y have different results depending on the platform;
# Windows 0-pad it, Linux does not.
# verifier.verify(date(1, 2, 3), '%G',
# expected_result='1')
verifier.verify(date(1, 2, 3), '%u',
expected_result='6')
verifier.verify(date(1, 2, 3), '%%',
expected_result='%')
verifier.verify(date(1, 2, 3), '%V',
expected_result='05')
Loading

0 comments on commit 81bbd40

Please sign in to comment.