Repository: incubator-brooklyn Updated Branches: refs/heads/master e668d15e2 -> c9445a286
clean up time API and support date parsing also a lot of help for time zones, because java seems bad with this, and making time methods consistent and less ambiguous Project: http://git-wip-us.apache.org/repos/asf/incubator-brooklyn/repo Commit: http://git-wip-us.apache.org/repos/asf/incubator-brooklyn/commit/9fe4c7c4 Tree: http://git-wip-us.apache.org/repos/asf/incubator-brooklyn/tree/9fe4c7c4 Diff: http://git-wip-us.apache.org/repos/asf/incubator-brooklyn/diff/9fe4c7c4 Branch: refs/heads/master Commit: 9fe4c7c413e6aeba6ec01cf2e10427d2829b8cb2 Parents: 1a28193 Author: Alex Heneveld <[email protected]> Authored: Thu Jun 4 16:53:17 2015 +0100 Committer: Alex Heneveld <[email protected]> Committed: Mon Jun 8 00:26:31 2015 +0100 ---------------------------------------------------------------------- docs/guide/ops/requirements.md | 17 +- .../main/java/brooklyn/util/text/Strings.java | 26 +- .../main/java/brooklyn/util/time/Duration.java | 7 +- .../main/java/brooklyn/util/time/Durations.java | 9 +- .../src/main/java/brooklyn/util/time/Time.java | 488 +++++++++++++++++-- .../test/java/brooklyn/util/time/TimeTest.java | 157 ++++++ 6 files changed, 658 insertions(+), 46 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/incubator-brooklyn/blob/9fe4c7c4/docs/guide/ops/requirements.md ---------------------------------------------------------------------- diff --git a/docs/guide/ops/requirements.md b/docs/guide/ops/requirements.md index 52b96e4..7830bc9 100644 --- a/docs/guide/ops/requirements.md +++ b/docs/guide/ops/requirements.md @@ -28,7 +28,7 @@ Brooklyn has also been tested on Ubuntu 12.04 and OS X. ## Software Requirements -Brooklyn requires Java (JRE or JDK), version 6 or version 7. The most recent version 7 is recommended. +Brooklyn requires Java (JRE or JDK) minimum version 6. The latest release of version 7 or 8 is recommended. OpenJDK is recommended. Brooklyn has also been tested on IBM J9 and Oracle's JVM. * check your `iptables` or other firewall service, making sure that incoming connections on port 8443 is not blocked @@ -54,6 +54,21 @@ For example, to open port 8443 in iptables, ues the command: /sbin/iptables -I INPUT -p TCP --dport 8443 -j ACCEPT +### Locale + +Brooklyn expects a sensible set of locale information and time zones to be available; +without this, some time-and-date handling may be surprising. + +Brooklyn parses and reports times according to the time zone set at the server. +If Brooklyn is targetting geographically distributed users, +it is normally recommended that the server's time zone be set to UTC. + + +### User Setup + +It is normally recommended that Brooklyn run as a non-root user with keys installed to `~/.ssh/id_rsa{,.pub}`. + + ### Linux Kernel Entropy Check that the [linux kernel entropy](increase-entropy.html) is sufficient. http://git-wip-us.apache.org/repos/asf/incubator-brooklyn/blob/9fe4c7c4/utils/common/src/main/java/brooklyn/util/text/Strings.java ---------------------------------------------------------------------- diff --git a/utils/common/src/main/java/brooklyn/util/text/Strings.java b/utils/common/src/main/java/brooklyn/util/text/Strings.java index 05957c4..cf61fad 100644 --- a/utils/common/src/main/java/brooklyn/util/text/Strings.java +++ b/utils/common/src/main/java/brooklyn/util/text/Strings.java @@ -246,7 +246,7 @@ public class Strings { return Joiner.on("\n").join(Arrays.asList(lines)); } - /** replaces all key->value entries from the replacement map in source (non-regex) */ + /** NON-REGEX - replaces all key->value entries from the replacement map in source (non-regex) */ @SuppressWarnings("rawtypes") public static String replaceAll(String source, Map replacements) { for (Object rr: replacements.entrySet()) { @@ -256,11 +256,20 @@ public class Strings { return source; } - /** NON-REGEX replaceAll - - * replaces all instances in source, of the given pattern, with the given replacement - * (not interpreting any arguments as regular expressions) - */ + /** NON-REGEX replaceAll - see the better, explicitly named {@link #replaceAllNonRegex(String, String, String)}. */ public static String replaceAll(String source, String pattern, String replacement) { + return replaceAllNonRegex(source, pattern, replacement); + } + + /** + * Replaces all instances in source, of the given pattern, with the given replacement + * (not interpreting any arguments as regular expressions). + * <p> + * This is actually the same as the very ambiguous {@link String#replace(CharSequence, CharSequence)}, + * which does replace all, but not using regex like the similarly ambiguous {@link String#replaceAll(String, String)} as. + * Alternatively see {@link #replaceAllRegex(String, String, String)}. + */ + public static String replaceAllNonRegex(String source, String pattern, String replacement) { if (source==null) return source; StringBuilder result = new StringBuilder(source.length()); for (int i=0; i<source.length(); ) { @@ -275,12 +284,7 @@ public class Strings { return result.toString(); } - /** NON-REGEX replacement -- explicit method name for reabaility, doing same as Strings.replaceAll */ - public static String replaceAllNonRegex(String source, String pattern, String replacement) { - return replaceAll(source, pattern, replacement); - } - - /** REGEX replacement -- explicit method name for reabaility, doing same as String.replaceAll */ + /** REGEX replacement -- explicit method name for reabaility, doing same as {@link String#replaceAll(String, String)}. */ public static String replaceAllRegex(String source, String pattern, String replacement) { return source.replaceAll(pattern, replacement); } http://git-wip-us.apache.org/repos/asf/incubator-brooklyn/blob/9fe4c7c4/utils/common/src/main/java/brooklyn/util/time/Duration.java ---------------------------------------------------------------------- diff --git a/utils/common/src/main/java/brooklyn/util/time/Duration.java b/utils/common/src/main/java/brooklyn/util/time/Duration.java index 0f73ef8..3107bf4 100644 --- a/utils/common/src/main/java/brooklyn/util/time/Duration.java +++ b/utils/common/src/main/java/brooklyn/util/time/Duration.java @@ -133,7 +133,10 @@ public class Duration implements Comparable<Duration>, Serializable { return nanos; } - /** see {@link #of(Object)} and {@link Time#parseTimeString(String)} */ + /** + * See {@link Time#parseElapsedTime(String)}; + * also accepts "forever" (and for those who prefer things exceedingly accurate, "practically_forever"). + * Also see {@link #of(Object)}. */ public static Duration parse(String textualDescription) { if (textualDescription==null) return null; if ("null".equalsIgnoreCase(textualDescription)) return null; @@ -142,7 +145,7 @@ public class Duration implements Comparable<Duration>, Serializable { if ("practicallyforever".equalsIgnoreCase(textualDescription)) return Duration.PRACTICALLY_FOREVER; if ("practically_forever".equalsIgnoreCase(textualDescription)) return Duration.PRACTICALLY_FOREVER; - return new Duration(Time.parseTimeString(textualDescription), TimeUnit.MILLISECONDS); + return new Duration((long) Time.parseElapsedTimeAsDouble(textualDescription), TimeUnit.MILLISECONDS); } /** creates new {@link Duration} instance of the given length of time */ http://git-wip-us.apache.org/repos/asf/incubator-brooklyn/blob/9fe4c7c4/utils/common/src/main/java/brooklyn/util/time/Durations.java ---------------------------------------------------------------------- diff --git a/utils/common/src/main/java/brooklyn/util/time/Durations.java b/utils/common/src/main/java/brooklyn/util/time/Durations.java index 60e77f4..3553ff4 100644 --- a/utils/common/src/main/java/brooklyn/util/time/Durations.java +++ b/utils/common/src/main/java/brooklyn/util/time/Durations.java @@ -40,8 +40,15 @@ public class Durations { try { if (timeout==null || timeout.toMilliseconds()<0 || Duration.PRACTICALLY_FOREVER.equals(timeout)) return Maybe.of(t.get()); - if (timeout.toMilliseconds()==0 && !t.isDone()) + if (timeout.toMilliseconds()==0 && !t.isDone()) { + for (int i=0; i<10; i++) { + // give it 10 nanoseconds to complete - heuristically this is often enough + // (Thread.yield should do it, but often seems to have no effect, e.g. on Mac) + Thread.yield(); + Thread.sleep(0, 1); + } return Maybe.absent("Task "+t+" not completed when immediate completion requested"); + } return Maybe.of(t.get(timeout.toMilliseconds(), TimeUnit.MILLISECONDS)); } catch (TimeoutException e) { return Maybe.absent("Task "+t+" did not complete within "+timeout); http://git-wip-us.apache.org/repos/asf/incubator-brooklyn/blob/9fe4c7c4/utils/common/src/main/java/brooklyn/util/time/Time.java ---------------------------------------------------------------------- diff --git a/utils/common/src/main/java/brooklyn/util/time/Time.java b/utils/common/src/main/java/brooklyn/util/time/Time.java index 2059223..8fa52cb 100644 --- a/utils/common/src/main/java/brooklyn/util/time/Time.java +++ b/utils/common/src/main/java/brooklyn/util/time/Time.java @@ -20,26 +20,41 @@ package brooklyn.util.time; import java.text.DateFormat; import java.text.ParseException; +import java.text.ParsePosition; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; +import java.util.List; +import java.util.SimpleTimeZone; import java.util.TimeZone; import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import brooklyn.util.collections.MutableList; import brooklyn.util.exceptions.Exceptions; +import brooklyn.util.guava.Maybe; import brooklyn.util.text.Strings; import com.google.common.base.Function; +import com.google.common.base.Preconditions; import com.google.common.base.Stopwatch; public class Time { + private static final Logger log = LoggerFactory.getLogger(Time.class); + + public static final String DATE_FORMAT_PREFERRED_W_TZ = "yyyy-MM-dd HH:mm:ss.SSS Z"; public static final String DATE_FORMAT_PREFERRED = "yyyy-MM-dd HH:mm:ss.SSS"; public static final String DATE_FORMAT_STAMP = "yyyyMMdd-HHmmssSSS"; public static final String DATE_FORMAT_SIMPLE_STAMP = "yyyy-MM-dd-HHmm"; + public static final String DATE_FORMAT_OF_DATE_TOSTRING = "EEE MMM dd HH:mm:ss zzz yyyy"; public static final long MILLIS_IN_SECOND = 1000; public static final long MILLIS_IN_MINUTE = 60*MILLIS_IN_SECOND; @@ -368,22 +383,36 @@ public class Time { return (result == 0) ? -1 : result; } - /** parses a string eg '5s' or '20m 22.123ms', returning the number of milliseconds it represents (rounded); - * -1 on blank or "never" or "off" or "false"; - * number of millis if no units specified. + /** Convenience for {@link Duration#parse(String)}. */ + public static Duration parseDuration(String timeString) { + return Duration.parse(timeString); + } + + /** + * As {@link #parseElapsedTimeAsDouble(String)}. Consider using {@link #parseDuration(String)} for a more usable return type. * * @throws NumberFormatException if cannot be parsed (or if null) */ + public static long parseElapsedTime(String timeString) { + return (long) parseElapsedTimeAsDouble(timeString); + } + /** @deprecated since 0.7.0 see {@link #parseElapsedTime(String)} */ @Deprecated public static long parseTimeString(String timeString) { - return (long) parseTimeStringAsDouble(timeString); + return (long) parseElapsedTime(timeString); } - - /** parses a string eg '5s' or '20m 22.123ms', returning the number of milliseconds it represents; -1 on blank or never or off or false; - * number of millis if no units specified. + /** @deprecated since 0.7.0 see {@link #parseElapsedTimeAsDouble(String)} */ @Deprecated + public static double parseTimeStringAsDouble(String timeString) { + return parseElapsedTimeAsDouble(timeString); + } + + /** + * Parses a string eg '5s' or '20m 22.123ms', returning the number of milliseconds it represents; + * -1 on blank or never or off or false. + * Assumes unit is millisections if no unit is specified. * * @throws NumberFormatException if cannot be parsed (or if null) */ - public static double parseTimeStringAsDouble(String timeString) { + public static double parseElapsedTimeAsDouble(String timeString) { if (timeString==null) throw new NumberFormatException("GeneralHelper.parseTimeString cannot parse a null string"); try { @@ -434,7 +463,7 @@ public class Time { double d = Double.parseDouble(num); double dd = 0; if (timeString.length()>0) { - dd = parseTimeStringAsDouble(timeString); + dd = parseElapsedTimeAsDouble(timeString); if (dd==-1) { throw new NumberFormatException("cannot combine '"+timeString+"' with '"+num+" "+s+"'"); } @@ -443,36 +472,433 @@ public class Time { } } + /** parses dates from string, accepting many formats including 'YYYY-MM-DD', 'YYYY-MM-DD HH:mm:SS', or millis since UTC epoch */ + public static Date parseDate(String input) { + if (input==null) return null; + return parseDateMaybe(input).get(); + } + + /** as {@link #parseDate(String)} but returning a {@link Maybe} rather than throwing or returning null */ + public static Maybe<Date> parseDateMaybe(String input) { + if (input==null) return Maybe.absent("value is null"); + input = input.trim(); + Maybe<Date> result; + + result = parseDateUtc(input); + if (result.isPresent()) return result; + + result = parseDateSimpleFlexibleFormatParser(input); + if (result.isPresent()) return result; + // return the error from this method + Maybe<Date> returnResult = result; + +// // see natty method comments below +// Maybe<Date> result = parseDateNatty(input); +// if (result.isPresent()) return result; + + result = parseDateFormat(input, new SimpleDateFormat(DATE_FORMAT_OF_DATE_TOSTRING)); + if (result.isPresent()) return result; + result = parseDateDefaultParse(input); + if (result.isPresent()) return result; + + return returnResult; + } + + @SuppressWarnings("deprecation") + private static Maybe<Date> parseDateDefaultParse(String input) { + try { + long ms = Date.parse(input); + if (ms>=new Date(1999, 12, 25).getTime() && ms <= new Date(2200, 1, 2).getTime()) { + // accept default date parse for this century and next + return Maybe.of(new Date(ms)); + } + } catch (Exception e) { + Exceptions.propagateIfFatal(e); + } + return Maybe.absent(); + } + + private static Maybe<Date> parseDateUtc(String input) { + if (input.matches("\\d+")) { + Maybe<Date> result = Maybe.of(new Date(Long.parseLong(input))); + if (result.isPresent()) { + @SuppressWarnings("deprecation") + int year = result.get().getYear(); + if (year >= 2000 && year < 2200) { + // only applicable for dates in this century + return result; + } else { + return Maybe.absent("long is probably not millis since epoch UTC; millis as string is not in acceptable range"); + } + } + } + return Maybe.absent("not long millis since epoch UTC"); + } + + private final static String DIGIT = "\\d"; + private final static String LETTER = "\\p{L}"; + private final static String COMMON_SEPARATORS = "-\\."; + private final static String TIME_SEPARATOR = COMMON_SEPARATORS+":"; + private final static String DATE_SEPARATOR = COMMON_SEPARATORS+"/ "; + private final static String TIME_ZONE_SEPARATOR = COMMON_SEPARATORS+":/ ,"; + private final static String DATE_TIME_SEPARATOR = TIME_ZONE_SEPARATOR+"T'"; + + private final static String DATE_ONLY_WITH_INNER_SEPARATORS = + namedGroup("year", DIGIT+DIGIT+DIGIT+DIGIT) + + anyChar(DATE_SEPARATOR) + + namedGroup("month", options(optionally(DIGIT)+DIGIT, anyChar(LETTER)+"+")) + + anyChar(DATE_SEPARATOR) + + namedGroup("day", optionally(DIGIT)+DIGIT); + private final static String DATE_WORDS_2 = + namedGroup("month", anyChar(LETTER)+"+") + + anyChar(DATE_SEPARATOR) + + namedGroup("day", optionally(DIGIT)+DIGIT) + + ",?"+anyChar(DATE_SEPARATOR)+"+" + + namedGroup("year", DIGIT+DIGIT+DIGIT+DIGIT); + // we could parse NN-NN-NNNN as DD-MM-YYYY always, but could be confusing for MM-DD-YYYY oriented people, so require month named + private final static String DATE_WORDS_3 = + namedGroup("day", optionally(DIGIT)+DIGIT) + + anyChar(DATE_SEPARATOR) + + namedGroup("month", anyChar(LETTER)+"+") + + ",?"+anyChar(DATE_SEPARATOR)+"+" + + namedGroup("year", DIGIT+DIGIT+DIGIT+DIGIT); + + private final static String DATE_ONLY_NO_SEPARATORS = + namedGroup("year", DIGIT+DIGIT+DIGIT+DIGIT) + + namedGroup("month", DIGIT+DIGIT) + + namedGroup("day", DIGIT+DIGIT); + + private final static String MERIDIAN = anyChar("aApP")+optionally(anyChar("mM")); + private final static String TIME_ONLY_WITH_INNER_SEPARATORS = + namedGroup("hours", optionally(DIGIT)+DIGIT)+ + optionally( + anyChar(TIME_SEPARATOR)+ + namedGroup("mins", DIGIT+DIGIT)+ + optionally( + anyChar(TIME_SEPARATOR)+ + namedGroup("secs", DIGIT+DIGIT+optionally( optionally("\\.")+DIGIT+"+"))))+ + optionally(" *" + namedGroup("meridian", notMatching(LETTER+LETTER+LETTER)+MERIDIAN)); + private final static String TIME_ONLY_NO_SEPARATORS = + namedGroup("hours", DIGIT+DIGIT)+ + namedGroup("mins", DIGIT+DIGIT)+ + optionally( + namedGroup("secs", DIGIT+DIGIT+optionally( optionally("\\.")+DIGIT+"+")))+ + namedGroup("meridian", ""); + + private final static String TZ_CODE = namedGroup("tzCode", + notMatching(MERIDIAN+options("$", anyChar("^"+LETTER))) // not AM or PM + + anyChar(LETTER)+"+"+anyChar(LETTER+DIGIT+"\\/\\-\\' _")+"*"); + private final static String TIME_ZONE_SIGNED_OFFSET = namedGroup("tz", options(namedGroup("tzOffset", options("\\+", "-")+ + DIGIT+optionally(DIGIT)+options("h", "H", "", optionally(":")+DIGIT+DIGIT)), + optionally("\\+")+TZ_CODE)); + private final static String TIME_ZONE_OPTIONALLY_SIGNED_OFFSET = namedGroup("tz", options(namedGroup("tzOffset", options("\\+", "-", " ")+ + options(optionally(DIGIT)+DIGIT+options("h", "H"), options("0"+DIGIT, "10", "11", "12")+optionally(":")+DIGIT+DIGIT)), + TZ_CODE)); + + @SuppressWarnings("deprecation") + private static Maybe<Date> parseDateSimpleFlexibleFormatParser(String input) { + input = input.trim(); + + String[] DATE_PATTERNS = new String[] { + DATE_ONLY_WITH_INNER_SEPARATORS, + DATE_ONLY_NO_SEPARATORS, + DATE_WORDS_2, + DATE_WORDS_3, + }; + String[] TIME_PATTERNS = new String[] { + TIME_ONLY_WITH_INNER_SEPARATORS, + TIME_ONLY_NO_SEPARATORS + }; + String[] TZ_PATTERNS = new String[] { + // space then time zone with sign (+-) or code is preferred + " " + TIME_ZONE_SIGNED_OFFSET, + // then no TZ - but declare the named groups + namedGroup("tz", namedGroup("tzOffset", "")+namedGroup("tzCode", "")), + // then any separator then offset with sign + anyChar(TIME_ZONE_SEPARATOR)+"+" + TIME_ZONE_SIGNED_OFFSET, + + // try parsing with enforced separators before TZ first + // (so e.g. in the case of DATE-0100, the -0100 is the time, not the timezone) + // then relax below (e.g. in the case of DATE-TIME+0100) + + // finally match DATE-TIME-1000 as time zone -1000 + // or DATE-TIME 1000 as TZ +1000 in case a + was supplied but converted to ' ' by web + // (but be stricter about the format, 2h or 0200 required, and hours <= 12 so as not to confuse with a year) + anyChar(TIME_ZONE_SEPARATOR)+"*" + TIME_ZONE_OPTIONALLY_SIGNED_OFFSET + }; + + List<String> basePatterns = MutableList.of(); + + // patterns with date first + String[] DATE_PATTERNS_UNCLOSED = new String[] { + // separator before time *required* if date had separators + DATE_ONLY_WITH_INNER_SEPARATORS + "(,?"+(namedGroup("sep", anyChar(DATE_TIME_SEPARATOR)+"+")), + // separator before time optional if date did not have separators + DATE_ONLY_NO_SEPARATORS + "("+(namedGroup("sep", anyChar(DATE_TIME_SEPARATOR)+"*")), + // separator before time required if date had words, comma allowed but needs something else (e.g. space), 'T' for UTC not supported + DATE_WORDS_2 + "(,?"+anyChar(DATE_TIME_SEPARATOR)+"+"+namedGroup("sep",""), + DATE_WORDS_3 + "(,?"+anyChar(DATE_TIME_SEPARATOR)+"+"+namedGroup("sep",""), + }; + for (String tzP: TZ_PATTERNS) + for (String dateP: DATE_PATTERNS_UNCLOSED) + for (String timeP: TIME_PATTERNS) + basePatterns.add("^" + dateP + timeP+")?" + tzP + "$"); + + // also allow time first, with TZ after, then before; separator needed but sep T for UTC not supported + for (String tzP: TZ_PATTERNS) + for (String dateP: DATE_PATTERNS) + for (String timeP: TIME_PATTERNS) + basePatterns.add("^" + timeP + ",?"+anyChar(DATE_TIME_SEPARATOR)+"+" +namedGroup("sep","") + dateP + tzP + "$"); + // also allow time first, with TZ after, then before; separator needed but sep T for UTC not supported + for (String tzP: TZ_PATTERNS) + for (String dateP: DATE_PATTERNS) + for (String timeP: TIME_PATTERNS) + basePatterns.add("^" + timeP + tzP + ",?"+anyChar(DATE_TIME_SEPARATOR)+"+" +namedGroup("sep","") + dateP + "$"); + + Maybe<Matcher> mm = Maybe.absent(); + for (String p: basePatterns) { + mm = match(p, input); + if (mm.isPresent()) break; + } + if (mm.isPresent()) { + Matcher m = mm.get(); + Calendar result; + + String tz = m.group("tz"); + String sep = m.group("sep"); + if (sep!=null && sep.trim().equals("T")) { + if (Strings.isNonBlank(tz)) { + return Maybe.absent("Cannot use 'T' separator and specify time zone ("+tz+")"); + } + tz = "UTC"; + } + + int year = Integer.parseInt(m.group("year")); + int day = Integer.parseInt(m.group("day")); + + String monthS = m.group("month"); + int month; + if (monthS.matches(DIGIT+"+")) { + month = Integer.parseInt(monthS)-1; + } else { + try { + month = new SimpleDateFormat("yyyy-MMM-dd").parse("2015-"+monthS+"-15").getMonth(); + } catch (ParseException e) { + return Maybe.absent("Unknown date format '"+input+"': invalid month '"+monthS+"'; try 'yyyy-MM-dd HH:mm:ss.SSS +0000'"); + } + } + + if (Strings.isNonBlank(tz)) { + TimeZone tzz = null; + String tzCode = m.group("tzCode"); + if (Strings.isNonBlank(tzCode)) { + tz = tzCode; + } + if (tz.matches(DIGIT+"+")) { + // stick a plus in front in case it was submitted by a web form and turned into a space + tz = "+"+tz; + } else { + tzz = getTimeZone(tz); + } + if (tzz==null) { + Maybe<Matcher> tmm = match("^ ?(?<tzH>(\\+|\\-||)"+DIGIT+optionally(DIGIT)+")(h|H||"+optionally(":")+"(?<tzM>"+DIGIT+DIGIT+"))$", tz); + if (tmm.isAbsent()) { + return Maybe.absent("Unknown date format '"+input+"': invalid timezone '"+tz+"'; try 'yyyy-MM-dd HH:mm:ss.SSS +0000'"); + } + Matcher tm = tmm.get(); + String tzM = tm.group("tzM"); + int offset = (60*Integer.parseInt(tm.group("tzH")) + Integer.parseInt("0"+(tzM!=null ? tzM : "")))*60; + tzz = new SimpleTimeZone(offset*1000, tz); + } + tz = getTimeZoneOffsetString(tzz, year, month, day); + result = new GregorianCalendar(tzz); + } else { + result = new GregorianCalendar(); + } + result.clear(); + + result.set(Calendar.YEAR, year); + result.set(Calendar.MONTH, month); + result.set(Calendar.DAY_OF_MONTH, day); + if (m.group("hours")!=null) { + int hours = Integer.parseInt(m.group("hours")); + String meridian = m.group("meridian"); + if (Strings.isNonBlank(meridian) && meridian.toLowerCase().startsWith("p")) { + if (hours>12) { + return Maybe.absent("Unknown date format '"+input+"': can't be "+hours+" PM; try 'yyyy-MM-dd HH:mm:ss.SSS +0000'"); + } + hours += 12; + } + result.set(Calendar.HOUR_OF_DAY, hours); + String minsS = m.group("mins"); + if (Strings.isNonBlank(minsS)) { + result.set(Calendar.MINUTE, Integer.parseInt(minsS)); + } + String secsS = m.group("secs"); + if (Strings.isBlank(secsS)) { + // leave at zero + } else if (secsS.matches(DIGIT+DIGIT+"?")) { + result.set(Calendar.SECOND, Integer.parseInt(secsS)); + } else { + double s = Double.parseDouble(secsS); + if (s>=0 && s<=60) { + // in double format, with correct period + } else if (secsS.length()==5) { + // allow ssSSS with no punctuation + s = s/=1000; + } else { + return Maybe.absent("Unknown date format '"+input+"': invalid seconds '"+secsS+"'; try 'YYYY-MM-DD HH:mm:ss.SSS +0000'"); + } + result.set(Calendar.SECOND, (int)s); + result.set(Calendar.MILLISECOND, (int)((s*1000) % 1000)); + } + } + + return Maybe.of(result.getTime()); + } + return Maybe.absent("Unknown date format '"+input+"'; try ISO-8601, or 'yyyy-MM-dd' or 'yyyy-MM-dd HH:mm:ss +0000'"); + } + + public static TimeZone getTimeZone(String code) { + if (code.indexOf('/')==-1) { + if ("Z".equals(code)) return getTimeZone("UTC"); + + // get the time zone -- most short codes aren't accepted, so accept (and prefer) certain common codes + if ("EST".equals(code)) return getTimeZone("America/New_York"); + if ("EDT".equals(code)) return getTimeZone("America/New_York"); + if ("PST".equals(code)) return getTimeZone("America/Los_Angeles"); + if ("PDT".equals(code)) return getTimeZone("America/Los_Angeles"); + if ("CST".equals(code)) return getTimeZone("America/Chicago"); + if ("CDT".equals(code)) return getTimeZone("America/Chicago"); + if ("MST".equals(code)) return getTimeZone("America/Denver"); + if ("MDT".equals(code)) return getTimeZone("America/Denver"); + + if ("BST".equals(code)) return getTimeZone("Europe/London"); // otherwise BST is Bangladesh! + if ("CEST".equals(code)) return getTimeZone("Europe/Paris"); + // IST falls through to below, where it is treated as India (not Irish); IDT not recognised + } + + TimeZone tz = TimeZone.getTimeZone(code); + if (tz!=null && !tz.equals(TimeZone.getTimeZone("GMT"))) { + // recognized + return tz; + } + // possibly unrecognized -- GMT returned if not known, bad TimeZone API! + String timeZones[] = TimeZone.getAvailableIDs(); + for (String tzs: timeZones) { + if (tzs.equals(code)) return tz; + } + // definitely unrecognized + return null; + } + + /** convert a TimeZone e.g. Europe/London to an offset string as at the given day, e.g. +0100 or +0000 depending daylight savings, + * absent with nice error if zone unknown */ + public static Maybe<String> getTimeZoneOffsetString(String tz, int year, int month, int day) { + TimeZone tzz = getTimeZone(tz); + if (tzz==null) return Maybe.absent("Unknown time zone code: "+tz); + return Maybe.of(getTimeZoneOffsetString(tzz, year, month, day)); + } + + /** as {@link #getTimeZoneOffsetString(String, int, int, int)} where the {@link TimeZone} is already instantiated */ + @SuppressWarnings("deprecation") + public static String getTimeZoneOffsetString(TimeZone tz, int year, int month, int day) { + int tzMins = tz.getOffset(new Date(year, month, day).getTime())/60/1000; + String tzStr = (tzMins<0 ? "-" : "+") + Strings.makePaddedString(""+(Math.abs(tzMins)/60), 2, "0", "")+Strings.makePaddedString(""+(Math.abs(tzMins)%60), 2, "0", ""); + return tzStr; + } + + private static String namedGroup(String name, String pattern) { + return "(?<"+name+">"+pattern+")"; + } + private static String anyChar(String charSet) { + return "["+charSet+"]"; + } + private static String optionally(String pattern) { + return "("+pattern+")?"; + } + private static String options(String ...patterns) { + return "("+Strings.join(patterns,"|")+")"; + } + private static String notMatching(String pattern) { + return "(?!"+pattern+")"; + } + + private static Maybe<Matcher> match(String pattern, String input) { + Matcher m = Pattern.compile(pattern).matcher(input); + if (m.find() && m.start()==0 && m.end()==input.length()) + return Maybe.of(m); + return Maybe.absent(); + } + +// // TODO https://github.com/joestelmach/natty is cool, but it drags in ANTLR, +// // it doesn't support dashes between date and time, and +// // it encourages relative time which would be awesome but only if we resolved it on read +// private static Maybe<Date> parseDateNatty(String input) { +// Parser parser = new Parser(); +// List<DateGroup> groups = parser.parse(input); +// if (groups.size()!=1) { +// return Maybe.absent("Invalid date format (multiple dates): "+input); +// } +// DateGroup group = Iterables.getOnlyElement(groups); +// if (!group.getText().equals(input)) { +// return Maybe.absent("Invalid date format (incomplete parse): "+input+" -> "+group.getText()); +// } +// if (group.isRecurring()) { +// return Maybe.absent("Invalid date format (recurring): "+input); +// } +// +// List<Date> dates = group.getDates(); +// if (dates.size()!=1) { +// return Maybe.absent("Invalid date format (multiple dates): "+input); +// } +// Date d = Iterables.getOnlyElement(dates); +// +// if (group.isTimeInferred()) { +// GregorianCalendar c1 = new GregorianCalendar(); +// c1.setTime(d); +// c1 = new GregorianCalendar(c1.get(Calendar.YEAR), c1.get(Calendar.MONTH), c1.get(Calendar.DAY_OF_MONTH)); +// d = c1.getTime(); +// } +// +// return Maybe.of(d); +// } + /** * Parses the given date, accepting either a UTC timestamp (i.e. a long), or a formatted date. - * @param dateString - * @param format - * @return + * <p> + * If no time zone supplied, this defaults to the TZ configured at the brooklyn server. + * + * @deprecated since 0.7.0 use {@link #parseDate(String)} for general or {@link #parseDateFormat(String, DateFormat)} for a format, + * plus {@link #parseDateUtc(String)} if you want to accept UTC */ public static Date parseDateString(String dateString, DateFormat format) { - if (dateString == null) - throw new NumberFormatException("GeneralHelper.parseDateString cannot parse a null string"); - - try { - return format.parse(dateString); - } catch (ParseException e) { - // continue trying - } + Maybe<Date> r = parseDateFormat(dateString, format); + if (r.isPresent()) return r.get(); - try { - // fix the usual ' ' => '+' nonsense when passing dates as query params - // note this is not URL unescaping, but converting to the date format that wants "+" in the middle and not spaces - String transformedDateString = dateString.replace(" ", "+"); - return format.parse(transformedDateString); - } catch (ParseException e) { - // continue trying + r = parseDateUtc(dateString); + if (r.isPresent()) return r.get(); + + throw new IllegalArgumentException("Date " + dateString + " cannot be parsed as UTC millis or using format " + format); + } + public static Maybe<Date> parseDateFormat(String dateString, String format) { + return parseDateFormat(dateString, new SimpleDateFormat(format)); + } + public static Maybe<Date> parseDateFormat(String dateString, DateFormat format) { + if (dateString == null) { + throw new NumberFormatException("GeneralHelper.parseDateString cannot parse a null string"); } + Preconditions.checkNotNull(format, "date format"); + dateString = dateString.trim(); - try { - return new Date(Long.parseLong(dateString.trim())); // try UTC millis number - } catch (NumberFormatException e) { - throw new IllegalArgumentException("Date " + dateString + " cannot be parsed as UTC millis or using format " + format); + ParsePosition p = new ParsePosition(0); + Date result = format.parse(dateString, p); + if (result!=null) { + // accept results even if the entire thing wasn't parsed, as enough was to match the requested format + return Maybe.of(result); } + if (log.isTraceEnabled()) log.trace("Could not parse date "+dateString+" using format "+format+": "+p); + return Maybe.absent(); } /** removes milliseconds from the date object; needed if serializing to ISO-8601 format http://git-wip-us.apache.org/repos/asf/incubator-brooklyn/blob/9fe4c7c4/utils/common/src/test/java/brooklyn/util/time/TimeTest.java ---------------------------------------------------------------------- diff --git a/utils/common/src/test/java/brooklyn/util/time/TimeTest.java b/utils/common/src/test/java/brooklyn/util/time/TimeTest.java index 0771b90..ae9f78e 100644 --- a/utils/common/src/test/java/brooklyn/util/time/TimeTest.java +++ b/utils/common/src/test/java/brooklyn/util/time/TimeTest.java @@ -19,6 +19,7 @@ package brooklyn.util.time; import java.util.Date; +import java.util.TimeZone; import org.testng.Assert; import org.testng.annotations.Test; @@ -149,4 +150,160 @@ public class TimeTest { Assert.assertFalse(Time.hasElapsedSince(aFewSecondsAgo, Duration.TEN_SECONDS)); Assert.assertTrue(Time.hasElapsedSince(-1, Duration.TEN_SECONDS)); } + + @Test(groups="Integration") //because it depends on TZ's set up and parsing months + public void testTimeZones() { + // useful to debug, if new special time zones needed +// for (String id: TimeZone.getAvailableIDs()) { +// TimeZone tz = TimeZone.getTimeZone(id); +// System.out.println(id+": "+tz.getDisplayName()+" "+tz.getDisplayName(true, TimeZone.SHORT)+" "+tz); +// } + + Assert.assertEquals("+0100", Time.getTimeZoneOffsetString("Europe/London", 2015, 6, 4).get()); + + Assert.assertEquals("-0500", Time.getTimeZoneOffsetString("EST", 2015, 1, 4).get()); + Assert.assertEquals("-0400", Time.getTimeZoneOffsetString("America/New_York", 2015, 6, 4).get()); + Assert.assertEquals("-0500", Time.getTimeZoneOffsetString("America/New_York", 2015, 1, 4).get()); + + // BST treated as British Time (not Bangladesh) + Assert.assertEquals("+0000", Time.getTimeZoneOffsetString("BST", 2015, 1, 4).get()); + Assert.assertEquals("+0100", Time.getTimeZoneOffsetString("BST", 2015, 6, 4).get()); + + // EST treated as EDT not fixed -0500 + Assert.assertEquals("-0400", Time.getTimeZoneOffsetString("EST", 2015, 6, 4).get()); + + // these normally not recognized + Assert.assertEquals("-0400", Time.getTimeZoneOffsetString("EDT", 2015, 6, 4).get()); + Assert.assertEquals("-0500", Time.getTimeZoneOffsetString("EDT", 2015, 1, 4).get()); + + Assert.assertEquals("-0600", Time.getTimeZoneOffsetString("CST", 2015, 1, 4).get()); + Assert.assertEquals("-0700", Time.getTimeZoneOffsetString("MST", 2015, 1, 4).get()); + Assert.assertEquals("-0800", Time.getTimeZoneOffsetString("PST", 2015, 1, 4).get()); + + Assert.assertEquals("+0530", Time.getTimeZoneOffsetString("IST", 2015, 1, 4).get()); + } + + @Test + public void testParseDate() { + doTestParseDate(false); + } + + @Test(groups="Integration") //because it depends on TZ's set up and parsing months + public void testParseDateIntegration() { + doTestParseDate(true); + } + + private void doTestParseDate(boolean integration) { + // explicit TZ inclusion + assertDatesParseToEqual("2015.6.4.0000 +0100", "2015-06-04-0000 +0100"); + assertDatesParseToEqual("2015.6.4.0100 +0100", "2015-06-04-0000 +0000"); + assertDatesParseToEqual("2015.6.4.0100 -0100", "2015-06-04-0200 +0000"); + if (integration) assertDatesParseToEqual("20150604 BST", "2015-06-04 +0100"); + + // no TZ uses server default + assertDatesParseToEqual("2015.6.4.0000", "2015-06-04-0000 "+Time.getTimeZoneOffsetString(TimeZone.getDefault(), 2015, 6, 4)); + assertDatesParseToEqual("20150604", "2015-06-04-0000"); + + // parse TZ + if (integration) { + assertDatesParseToEqual("20150604 +BST", "2015-06-04 +0100"); + assertDatesParseToEqual("20150604 - - - BST", "2015-06-04 +0100"); + assertDatesParseToEqual("20150604--BST", "2015-06-04 +0100"); + assertDatesParseToEqual("20150604-//-BST", "2015-06-04 +0100"); + } + assertDatesParseToEqual("2015.6.4+0100", "2015-06-04-0000+0100"); + assertDatesParseToEqual("20150604-+0100", "2015-06-04 +0100"); + assertDatesParseToEqual("2015-6-4 +0100", "2015-06-04-0000 +0100"); + assertDatesParseToEqual("2015-6-4 -0100", "2015-06-04-0000 -0100"); + assertDatesParseToEqual("20150604-0000//-0100", "2015-06-04 -0100"); + // ambiguous TZ/hours parse prefers hours + assertDatesParseToEqual("2015-6-4-0100", "2015-06-04-0100"); + assertDatesParseToEqual("2015-6-4--0100", "2015-06-04-0100"); + + // formats without spaces + assertDatesParseToEqual("20150604080012", "2015-06-04-080012"); + assertDatesParseToEqual("20150604080012 +1000", "2015-06-03-220012 +0000"); + assertDatesParseToEqual("20150604080012 -1000", "2015-06-04-180012 +0000"); + assertDatesParseToEqual("20150604080012.345 +1000", "2015-06-03-220012.345 +0000"); + if (integration) { + assertDatesParseToEqual("20150604 BST", "2015-06-04 +0100"); + assertDatesParseToEqual("20150604 Europe/London", "2015-06-04 +0100"); + } + + // more misc tests + assertDatesParseToEqual("20150604 08:00:12.345", "2015-06-04-080012.345"); + assertDatesParseToEqual("20150604-080012.345", "2015-06-04-080012.345"); + assertDatesParseToEqual("2015-12-1", "2015-12-01-0000"); + assertDatesParseToEqual("1066-12-1", "1066-12-01-0000"); + Assert.assertEquals(Time.parseDate("2012-2-29").getTime(), Time.parseDate("2012-3-1").getTime() - 24*60*60*1000); + // perverse, but accepted for the time being: + Assert.assertEquals(Time.parseDate("2013-2-29").getTime(), Time.parseDate("2013-3-1").getTime()); + + assertDatesParseToEqual("20150604T080012.345", "2015-06-04-080012.345+0000"); + assertDatesParseToEqual("20150604080012.345Z", "2015-06-04-080012.345+0000"); + + // accept am and pm + assertDatesParseToEqual("20150604 08:00:12.345a", "2015-06-04-080012.345"); + assertDatesParseToEqual("20150604 08:00:12.345 PM", "2015-06-04-200012.345"); + if (integration) assertDatesParseToEqual("20150604 08:00:12.345 am BST", "2015-06-04-080012.345 +0100"); + + // accept month in words + if (integration) { + assertDatesParseToEqual("2015-Dec-1", "2015-12-01-0000"); + assertDatesParseToEqual("2015 Dec 1", "2015-12-01-0000"); + assertDatesParseToEqual("2015-DEC-1", "2015-12-01-0000"); + assertDatesParseToEqual("2015 December 1", "2015-12-01-0000"); + assertDatesParseToEqual("2015 December 1", "2015-12-01-0000"); + assertDatesParseToEqual("2015-Mar-1", "2015-03-01-0000"); + assertDatesParseToEqual("2015 Mar 1", "2015-03-01-0000"); + assertDatesParseToEqual("2015-MAR-1", "2015-03-01-0000"); + assertDatesParseToEqual("2015 March 1", "2015-03-01-0000"); + assertDatesParseToEqual("2015 March 1", "2015-03-01-0000"); + } + + // for month in words, allow selected other orders also + if (integration) { + assertDatesParseToEqual("1-Jun-2015", "2015-06-01-0000"); + assertDatesParseToEqual("Jun 1, 2015", "2015-06-01-0000"); + assertDatesParseToEqual("June 1, 2015, 4pm", "2015-06-01-1600"); + } + + // also allow time first if separators are used + assertDatesParseToEqual("16:00, 2015-12-30", "2015-12-30-1600"); + if (integration) { + assertDatesParseToEqual("4pm, Dec 1, 2015", "2015-12-01-1600"); + assertDatesParseToEqual("16:00 30-Dec-2015", "2015-12-30-1600"); + } + + // and if time comes first, TZ can be before or after date + assertDatesParseToEqual("4pm +0100, 2015-12-30", "2015-12-30-1600 +0100"); + assertDatesParseToEqual("4pm, 2015-12-30, +0100", "2015-12-30-1600 +0100"); + + // these ambiguous ones are accepted (maybe we'd rather not), + // but they are interpreted sensibly, preferring the more sensible interpretation + if (integration) assertDatesParseToEqual("16 Dec 1 2015", "2015-12-01-1600"); + if (integration) assertDatesParseToEqual("16:30 1067 Dec 1 1066", "1067-12-01-1630 +1066"); + assertDatesParseToEqual("1040 1045 12 1", "1045-12-01-1040"); + assertDatesParseToEqual("1040 1045 12 1 +0h", "1045-12-01-1040Z"); + if (integration) assertDatesParseToEqual("1045 Dec 1 1040", "1045-12-01-1040"); + if (integration) assertDatesParseToEqual("10:40 Dec 1 1045", "1045-12-01-1040"); + assertDatesParseToEqual("10.11-2020-12.01", "2020-12-01-1011"); + if (integration) assertDatesParseToEqual("Oct.11 1045 12.01", "1045-10-11-1201"); + if (integration) assertDatesParseToEqual("1040 1045 Dec 1 1030", "1045-12-01-1040 +1030"); + assertDatesParseToEqual("1040 +02 2015 12 1", "2015-12-01-1040 +0200"); + assertDatesParseToEqual("10:40:+02 2015 12 1", "2015-12-01-1040 +0200"); + } + + @SuppressWarnings("deprecation") + @Test + public void testParseDateToString() { + // java.lang.AssertionError: for: Sun Jun 07 21:21:00 BST 2015 (20150607-212100073) expected [Sun Jun 07 21:22:13 BST 2015] but found [Sun Jun 07 21:21:00 BST 2015] + Date d = new Date(); + d.setSeconds(0); + assertDatesParseToEqual(d.toString(), Time.makeDateStampString(d.getTime())); + } + + private void assertDatesParseToEqual(String input, String expected) { + Assert.assertEquals(Time.parseDate(input).toString(), Time.parseDate(expected).toString(), "for: "+input+" ("+expected+")"); + } }
