diff --git a/HOCON.md b/HOCON.md
index 168a0a33..f85c4b05 100644
--- a/HOCON.md
+++ b/HOCON.md
@@ -1296,7 +1296,27 @@ must be lowercase. Exactly these strings are supported:
- `m`, `minute`, `minutes`
- `h`, `hour`, `hours`
- `d`, `day`, `days`
+
+### Period Format
+
+Similar to the `getDuration()` method, there is a `getPeriod()` method
+available for getting time units as a `java.time.Period`.
+This can use the general "units format" described above; bare
+numbers are taken to be in days, while strings are
+parsed as a number plus an optional unit string.
+
+The supported unit strings for period are case sensitive and
+must be lowercase. Exactly these strings are supported:
+
+ - `d`, `day`, `days`
+ - `w`, `week`, `weeks`
+ - `m`, `mo`, `month`, `months` (note that if you are using `getTemporal()`
+ which may return either a `java.time.Duration` or a `java.time.Period`
+ you will want to use `mo` rather than `m` to prevent your unit being
+ parsed as minutes)
+ - `y`, `year`, `years`
+
### Size in bytes format
Implementations may wish to support a `getBytes()` returning a
diff --git a/config/src/main/java/com/typesafe/config/Config.java b/config/src/main/java/com/typesafe/config/Config.java
index 0097eed6..68a36e78 100644
--- a/config/src/main/java/com/typesafe/config/Config.java
+++ b/config/src/main/java/com/typesafe/config/Config.java
@@ -4,6 +4,8 @@
package com.typesafe.config;
import java.time.Duration;
+import java.time.Period;
+import java.time.temporal.TemporalAmount;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -793,6 +795,44 @@ public interface Config extends ConfigMergeable {
*/
Duration getDuration(String path);
+ /**
+ * Gets a value as a java.time.Period. If the value is
+ * already a number, then it's taken as days; if it's
+ * a string, it's parsed understanding units suffixes like
+ * "10d" or "5w" as documented in the the
+ * spec. This method never returns null.
+ *
+ * @since 1.3.0
+ *
+ * @param path
+ * path expression
+ * @return the period value at the requested path
+ * @throws ConfigException.Missing
+ * if value is absent or null
+ * @throws ConfigException.WrongType
+ * if value is not convertible to Long or String
+ * @throws ConfigException.BadValue
+ * if value cannot be parsed as a number of the given TimeUnit
+ */
+ Period getPeriod(String path);
+
+ /**
+ * Gets a value as a java.time.temporal.TemporalAmount.
+ * This method will first try get get the value as a java.time.Duration, and if unsuccessful,
+ * then as a java.time.Period.
+ * This means that values like "5m" will be parsed as 5 minutes rather than 5 months
+ * @param path path expression
+ * @return the temporal value at the requested path
+ * @throws ConfigException.Missing
+ * if value is absent or null
+ * @throws ConfigException.WrongType
+ * if value is not convertible to Long or String
+ * @throws ConfigException.BadValue
+ * if value cannot be parsed as a TemporalAmount
+ */
+ TemporalAmount getTemporal(String path);
+
/**
* Gets a list value (with any element type) as a {@link ConfigList}, which
* implements {@code java.util.List}. Throws if the path is
diff --git a/config/src/main/java/com/typesafe/config/impl/SimpleConfig.java b/config/src/main/java/com/typesafe/config/impl/SimpleConfig.java
index c3147124..ce1913ad 100644
--- a/config/src/main/java/com/typesafe/config/impl/SimpleConfig.java
+++ b/config/src/main/java/com/typesafe/config/impl/SimpleConfig.java
@@ -7,7 +7,11 @@ import java.io.ObjectStreamException;
import java.io.Serializable;
import java.math.BigDecimal;
import java.math.BigInteger;
+import java.time.DateTimeException;
import java.time.Duration;
+import java.time.Period;
+import java.time.temporal.ChronoUnit;
+import java.time.temporal.TemporalAmount;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.HashMap;
@@ -322,6 +326,21 @@ final class SimpleConfig implements Config, MergeableValue, Serializable {
return Duration.ofNanos(nanos);
}
+ @Override
+ public Period getPeriod(String path){
+ ConfigValue v = find(path, ConfigValueType.STRING);
+ return parsePeriod((String) v.unwrapped(), v.origin(), path);
+ }
+
+ @Override
+ public TemporalAmount getTemporal(String path){
+ try{
+ return getDuration(path);
+ } catch (ConfigException.BadValue e){
+ return getPeriod(path);
+ }
+ }
+
@SuppressWarnings("unchecked")
private List getHomogeneousUnwrappedList(String path,
ConfigValueType expected) {
@@ -583,6 +602,90 @@ final class SimpleConfig implements Config, MergeableValue, Serializable {
return s.substring(i + 1);
}
+ /**
+ * Parses a period string. If no units are specified in the string, it is
+ * assumed to be in days. The returned period is in days.
+ * The purpose of this function is to implement the period-related methods
+ * in the ConfigObject interface.
+ *
+ * @param input
+ * the string to parse
+ * @param originForException
+ * origin of the value being parsed
+ * @param pathForException
+ * path to include in exceptions
+ * @return duration in days
+ * @throws ConfigException
+ * if string is invalid
+ */
+ public static Period parsePeriod(String input,
+ ConfigOrigin originForException, String pathForException) {
+ String s = ConfigImplUtil.unicodeTrim(input);
+ String originalUnitString = getUnits(s);
+ String unitString = originalUnitString;
+ String numberString = ConfigImplUtil.unicodeTrim(s.substring(0, s.length()
+ - unitString.length()));
+ ChronoUnit units;
+
+ // this would be caught later anyway, but the error message
+ // is more helpful if we check it here.
+ if (numberString.length() == 0)
+ throw new ConfigException.BadValue(originForException,
+ pathForException, "No number in period value '" + input
+ + "'");
+
+ if (unitString.length() > 2 && !unitString.endsWith("s"))
+ unitString = unitString + "s";
+
+ // note that this is deliberately case-sensitive
+ if (unitString.equals("") || unitString.equals("d") || unitString.equals("days")) {
+ units = ChronoUnit.DAYS;
+
+ } else if (unitString.equals("w") || unitString.equals("weeks")) {
+ units = ChronoUnit.WEEKS;
+
+ } else if (unitString.equals("m") || unitString.equals("mo") || unitString.equals("months")) {
+ units = ChronoUnit.MONTHS;
+
+ } else if (unitString.equals("y") || unitString.equals("years")) {
+ units = ChronoUnit.YEARS;
+
+ } else {
+ throw new ConfigException.BadValue(originForException,
+ pathForException, "Could not parse time unit '"
+ + originalUnitString
+ + "' (try d, w, mo, y)");
+ }
+
+ try {
+ return periodOf(Integer.parseInt(numberString), units);
+ } catch (NumberFormatException e) {
+ throw new ConfigException.BadValue(originForException,
+ pathForException, "Could not parse duration number '"
+ + numberString + "'");
+ }
+ }
+
+
+ private static Period periodOf(int n, ChronoUnit unit){
+ if(unit.isTimeBased()){
+ throw new DateTimeException(unit + " cannot be converted to a java.time.Period");
+ }
+
+ switch (unit){
+ case DAYS:
+ return Period.ofDays(n);
+ case WEEKS:
+ return Period.ofWeeks(n);
+ case MONTHS:
+ return Period.ofMonths(n);
+ case YEARS:
+ return Period.ofYears(n);
+ default:
+ throw new DateTimeException(unit + " cannot be converted to a java.time.Period");
+ }
+ }
+
/**
* Parses a duration string. If no units are specified in the string, it is
* assumed to be in milliseconds. The returned duration is in nanoseconds.
diff --git a/config/src/test/resources/test01.conf b/config/src/test/resources/test01.conf
index 1370bf85..e40eb7f5 100644
--- a/config/src/test/resources/test01.conf
+++ b/config/src/test/resources/test01.conf
@@ -65,6 +65,14 @@
"minusLargeNanos" : -4878955355435272204ns
},
+ "periods" : {
+ "day" : 1d,
+ "dayAsNumber": 2,
+ "week": 3 weeks,
+ "month": 5 mo,
+ "year": 8y
+ },
+
"memsizes" : {
"meg" : 1M,
"megsList" : [1M, 1024K, 1048576],
diff --git a/config/src/test/scala/com/typesafe/config/impl/ConfigTest.scala b/config/src/test/scala/com/typesafe/config/impl/ConfigTest.scala
index 96b55835..0becca4b 100644
--- a/config/src/test/scala/com/typesafe/config/impl/ConfigTest.scala
+++ b/config/src/test/scala/com/typesafe/config/impl/ConfigTest.scala
@@ -3,13 +3,16 @@
*/
package com.typesafe.config.impl
+import java.time.temporal.{ ChronoUnit, TemporalUnit }
+
import org.junit.Assert._
import org.junit._
import com.typesafe.config._
import java.util.concurrent.TimeUnit
+
import scala.collection.JavaConverters._
import com.typesafe.config.ConfigResolveOptions
-import java.util.concurrent.TimeUnit.{ SECONDS, NANOSECONDS, MICROSECONDS, MILLISECONDS, MINUTES, DAYS, HOURS }
+import java.util.concurrent.TimeUnit.{ DAYS, HOURS, MICROSECONDS, MILLISECONDS, MINUTES, NANOSECONDS, SECONDS }
class ConfigTest extends TestUtils {
@@ -811,6 +814,13 @@ class ConfigTest extends TestUtils {
assertDurationAsTimeUnit(HOURS)
assertDurationAsTimeUnit(DAYS)
+ // periods
+ assertEquals(1, conf.getPeriod("periods.day").get(ChronoUnit.DAYS))
+ assertEquals(2, conf.getPeriod("periods.dayAsNumber").getDays)
+ assertEquals(3 * 7, conf.getTemporal("periods.week").get(ChronoUnit.DAYS))
+ assertEquals(5, conf.getTemporal("periods.month").get(ChronoUnit.MONTHS))
+ assertEquals(8, conf.getTemporal("periods.year").get(ChronoUnit.YEARS))
+
// should get size in bytes
assertEquals(1024 * 1024L, conf.getBytes("memsizes.meg"))
assertEquals(1024 * 1024L, conf.getBytes("memsizes.megAsNumber"))
diff --git a/config/src/test/scala/com/typesafe/config/impl/UnitParserTest.scala b/config/src/test/scala/com/typesafe/config/impl/UnitParserTest.scala
index b4ee9a1b..f03593d1 100644
--- a/config/src/test/scala/com/typesafe/config/impl/UnitParserTest.scala
+++ b/config/src/test/scala/com/typesafe/config/impl/UnitParserTest.scala
@@ -3,6 +3,9 @@
*/
package com.typesafe.config.impl
+import java.time.{ LocalDate, Period }
+import java.time.temporal.ChronoUnit
+
import org.junit.Assert._
import org.junit._
import com.typesafe.config._
@@ -40,6 +43,33 @@ class UnitParserTest extends TestUtils {
assertTrue(e2.getMessage.contains("duration number"))
}
+ @Test
+ def parsePeriod() = {
+ val oneYears = List(
+ "1y", "1 y", "1year", "1 years", " 1y ", " 1 y ",
+ "365", "365d", "365 d", "365 days", " 365 days ", "365day",
+ "12m", "12mo", "12 m", " 12 mo ", "12 months", "12month")
+ val epochDate = LocalDate.ofEpochDay(0)
+ val oneYear = ChronoUnit.DAYS.between(epochDate, epochDate.plus(Period.ofYears(1)))
+ for (y <- oneYears) {
+ val period = SimpleConfig.parsePeriod(y, fakeOrigin(), "test")
+ val dayCount = ChronoUnit.DAYS.between(epochDate, epochDate.plus(period))
+ assertEquals(oneYear, dayCount)
+ }
+
+ // bad units
+ val e = intercept[ConfigException.BadValue] {
+ SimpleConfig.parsePeriod("100 dollars", fakeOrigin(), "test")
+ }
+ assertTrue(s"${e.getMessage} was not the expected error message", e.getMessage.contains("time unit"))
+
+ // bad number
+ val e2 = intercept[ConfigException.BadValue] {
+ SimpleConfig.parsePeriod("1 00 seconds", fakeOrigin(), "test")
+ }
+ assertTrue(s"${e2.getMessage} was not the expected error message", e2.getMessage.contains("time unit 'seconds'"))
+ }
+
// https://github.com/typesafehub/config/issues/117
// this broke because "1d" is a valid double for parseDouble
@Test