diff --git a/Changes b/Changes index 1f4f0fd..80a642d 100644 --- a/Changes +++ b/Changes @@ -1,5 +1,11 @@ Revision history for Time-Piece +1.38 2025-10-18 + - Doc updates + - Fix Windows-2025 crash with %P + - XS clean up + - strptime: %z and %Z fixes (GH52,32,73,RT93095,168828) + 1.3701 2025-08-25 - Test fixes on Alpine and locales without AM/PM (GH76) diff --git a/MANIFEST b/MANIFEST index 738597b..6cbeef0 100644 --- a/MANIFEST +++ b/MANIFEST @@ -19,5 +19,7 @@ t/08truncate.t t/09locales.t t/10overload.t t/11strptime_defaults.t +t/12strptime_timezones.t +t/13date_arithmetic_edge_cases.t t/99legacy.t t/lib/Time/Piece/Twin.pm diff --git a/Piece.pm b/Piece.pm index 3542975..9c6839c 100644 --- a/Piece.pm +++ b/Piece.pm @@ -19,7 +19,7 @@ our %EXPORT_TAGS = ( ':override' => 'internal', ); -our $VERSION = '1.3701'; +our $VERSION = '1.38'; XSLoader::load( 'Time::Piece', $VERSION ); @@ -955,7 +955,6 @@ sub _default_locale { @{ $locales->{wday} } = @DAY_LIST; @{ $locales->{month} } = @FULLMON_LIST; @{ $locales->{mon} } = @MON_LIST; - $locales->{alt_month} = $locales->{month}; $locales->{PM} = 'PM'; $locales->{AM} = 'AM'; @@ -981,7 +980,7 @@ Time::Piece - Object Oriented time objects =head1 SYNOPSIS use Time::Piece; - + my $t = localtime; print "Time is $t\n"; print "Year is ", $t->year, "\n"; @@ -997,41 +996,53 @@ The module actually implements most of an interface described by Larry Wall on the perl5-porters mailing list here: L -=head1 USAGE -After importing this module, when you use localtime or gmtime in a scalar +After importing this module, when you use C or C in a scalar context, rather than getting an ordinary scalar string representing the -date and time, you get a Time::Piece object, whose stringification happens -to produce the same effect as the localtime and gmtime functions. There is -also a new() constructor provided, which is the same as localtime(), except -when passed a Time::Piece object, in which case it's a copy constructor. The -following methods are available on the object: +date and time, you get a C object, whose stringification happens +to produce the same effect as the C and C functions. + +The primary way to create Time::Piece objects is through the C and +C functions. There is also a C constructor which is the same as +C, except when passed a Time::Piece object, in which case it's a +copy constructor. + +=head1 Public Methods + +The following methods are available on the object: + +=head2 Time Components $t->sec # also available as $t->second $t->min # also available as $t->minute $t->hour # 24 hour + +=head2 Date Components + $t->mday # also available as $t->day_of_month $t->mon # 1 = January $t->_mon # 0 = January - $t->monname # Feb - $t->month # same as $t->monname - $t->fullmonth # February $t->year # based at 0 (year 0 AD is, of course 1 BC) $t->_year # year minus 1900 $t->yy # 2 digit year + +=head2 Day and Month Names + + $t->monname # Feb + $t->month # same as $t->monname + $t->fullmonth # February $t->wday # 1 = Sunday $t->_wday # 0 = Sunday $t->day_of_week # 0 = Sunday $t->wdayname # Tue $t->day # same as wdayname $t->fullday # Tuesday - $t->yday # also available as $t->day_of_year, 0 = Jan 01 - $t->isdst # also available as $t->daylight_savings + +=head2 Formatted Date/Time Output $t->hms # 12:34:56 $t->hms(".") # 12.34.56 $t->time # same as $t->hms - $t->ymd # 2000-02-29 $t->date # same as $t->ymd $t->mdy # 02-29-2000 @@ -1041,69 +1052,68 @@ following methods are available on the object: $t->datetime # 2000-02-29T12:34:56 (ISO 8601) $t->cdate # Tue Feb 29 12:34:56 2000 "$t" # same as $t->cdate + $t->strftime(FORMAT) # same as POSIX::strftime (without the overhead + # of the full POSIX extension) + $t->strftime() # "Tue, 29 Feb 2000 12:34:56 GMT" - $t->epoch # seconds since the epoch - $t->tzoffset # timezone offset in a Time::Seconds object +=head2 Epoch and Calendar Calculations + $t->epoch # seconds since the epoch $t->julian_day # number of days since Julian period began $t->mjd # modified Julian date (JD-2400000.5 days) - $t->week # week number (ISO 8601) + $t->yday # also available as $t->day_of_year, 0 = Jan 01 - $t->is_leap_year # true if it's a leap year - $t->month_last_day # 28-31 +=head2 Timezone and DST - $t->time_separator($s) # set the default separator (default ":") - $t->date_separator($s) # set the default separator (default "-") - $t->day_list(@days) # set the default weekdays - $t->mon_list(@days) # set the default months + $t->tzoffset # timezone offset in a Time::Seconds object + $t->isdst # also available as $t->daylight_savings - $t->strftime(FORMAT) # same as POSIX::strftime (without the overhead - # of the full POSIX extension) - $t->strftime() # "Tue, 29 Feb 2000 12:34:56 GMT" - - Time::Piece->strptime(STRING, FORMAT) - # see strptime man page. Creates a new - # Time::Piece object +The C method returns: -Note that C and C are not listed above. If called as -methods on a Time::Piece object, they act as constructors, returning a new -Time::Piece object for the current time. In other words: they're not useful as -methods. +=over 4 + +=item * 0 for GMT/UTC times (they never have DST) -=head2 Local Locales +=item * 0 or 1 for local times depending on whether DST is active -Both C (day) and C (month) allow passing in a list to use -to index the name of the days against. This can be useful if you need -to implement some form of localisation without actually installing or -using locales. Note that this is a global override and will affect -all Time::Piece instances. +=item * Automatically calculated if unknown - my @days = qw( Dimanche Lundi Merdi Mercredi Jeudi Vendredi Samedi ); +=back - my $french_day = localtime->day(@days); +The C method returns the offset from UTC as a Time::Seconds object. +For GMT/UTC times, this always returns 0. For local times, it calculates +the actual offset including any DST adjustment. -These settings can be overridden globally too: +=head2 Utility Methods - Time::Piece::day_list(@days); + $t->is_leap_year # true if it's a leap year + $t->month_last_day # 28-31 -Or for months: +=head2 Global Configuration - Time::Piece::mon_list(@months); + $t->time_separator($s) # set the default separator (default ":") + $t->date_separator($s) # set the default separator (default "-") + $t->day_list(@days) # set the default weekdays + $t->mon_list(@days) # set the default months -And locally for months: +=head2 Parsing - print localtime->month(@months); + Time::Piece->strptime(STRING, FORMAT) + # see strptime man page. Creates a new + # Time::Piece object -Or to populate with your current system locale call: - Time::Piece->use_locale(); +B C and C are not listed above. If called as +methods on a Time::Piece object, they act as constructors, returning a new +Time::Piece object for the current time. In other words: they're not useful as +methods. -=head2 Date Calculations +=head1 Date Calculations It's possible to use simple addition and subtraction of objects: use Time::Seconds; - + my $seconds = $t1 - $t2; $t1 += ONE_DAY; # add 1 day (constant from Time::Seconds) @@ -1113,29 +1123,42 @@ The following are valid ($t1 and $t2 are Time::Piece objects): $t1 - 42; # returns Time::Piece object $t1 + 533; # returns Time::Piece object -However adding a Time::Piece object to another Time::Piece object -will cause a runtime error. +B All arithmetic uses epoch seconds (UTC). When daylight saving time +(DST) changes occur: + +=over 4 -Note that the first of the above returns a Time::Seconds object, so -while examining the object will print the number of seconds (because -of the overloading), you can also get the number of minutes, hours, -days, weeks and years in that delta, using the Time::Seconds API. +=item * Adding seconds works on UTC time, so adding 3600 seconds during DST +transition from 1:30 AM gives 3:30 AM (not 2:30 AM, which doesn't exist +during "spring forward") -In addition to adding seconds, there are two APIs for adding months and -years: +=item * Subtracting across DST transitions may differ from wall-clock expectations +due to skipped or repeated hours + +=back + +=head2 Adding Months and Years + +Two methods handle calendar arithmetic differently than seconds-based math: $t = $t->add_months(6); $t = $t->add_years(5); -The months and years can be negative for subtractions. Note that there -is some "strange" behaviour when adding and subtracting months at the -ends of months. Generally when the resulting month is shorter than the -starting month then the number of overlap days is added. For example -subtracting a month from 2008-03-31 will not result in 2008-02-31 as this -is an impossible date. Instead you will get 2008-03-02. This appears to -be consistent with other date manipulation tools. +B + +=over 4 + +=item * These preserve the day-of-month number, which can cause overflow (Jan 31 + 1 +month = Mar 3, since "Feb 31" doesn't exist) + +=item * Wall-clock time is preserved across DST transitions -=head2 Truncation +=item * Order matters: C then C<+ 86400> gives different results than +C<+ 86400> then C + +=back + +=head1 Truncation Calling the C method returns a copy of the object but with the time truncated to the start of the supplied unit. @@ -1146,16 +1169,35 @@ This example will set the time to midnight on the same date which C<$t> had previously. Allowed values for the "to" parameter are: "year", "quarter", "month", "day", "hour", "minute" and "second". -=head2 Date Comparisons +=head1 Date Comparisons Date comparisons are also possible, using the full suite of "<", ">", "<=", ">=", "<=>", "==" and "!=". -=head2 Date Parsing +All comparisons use epoch seconds, so they work correctly across timezones: + + my $t1 = localtime; + my $t2 = gmtime; + if ($t1 > $t2) { # Compares actual moments in time, not clock values + # ... + } + +Time::Piece objects can also be compared as strings using C: -Time::Piece provides flexible date parsing via the built-in strptime() function (from FreeBSD). + if ($t1 cmp "2024-01-15") { # Compares against cdate format + # ... + } + +=head1 Date Parsing + +Time::Piece provides flexible date parsing via the built-in C +function (from FreeBSD). -=head3 Basic Usage +For more information on acceptible formats and flags for C see +"man strptime" on unix systems. Alternatively look here: +L + +=head2 Basic Usage my $t = Time::Piece->strptime("Sunday 3rd Nov, 1943", "%A %drd %b, %Y"); @@ -1166,111 +1208,157 @@ Outputs: Wed, 03 Nov 1943 -(see, it's even smart enough to fix my obvious date bug) +The default format string is C<"%a, %d %b %Y %H:%M:%S %Z">, so these are equivalent: -=head3 Default Values for Partial Dates + my $t1 = Time::Piece->strptime($string); + my $t2 = Time::Piece->strptime($string, "%a, %d %b %Y %H:%M:%S %Z"); -When parsing incomplete date strings, you can provide defaults for missing components: +=head2 Handling Partial Dates -=head4 Supported Default Types +When parsing incomplete date strings, you can provide defaults for missing +components in several ways: -B<1. Array Reference> - Standard time components (sec, min, hour, mday, mon, year, wday, yday) (see C): +B - Standard time components (as returned by localtime): - my @defaults = localtime(); - my $t = Time::Piece->strptime("15 Mar", "%d %b", - { defaults => \@defaults }); + my @defaults = localtime(); + my $t = Time::Piece->strptime("15 Mar", "%d %b", + { defaults => \@defaults }); -B<2. Hash Reference> - Specify only needed components: +B - Specify only needed components: - my $t = Time::Piece->strptime("15 Mar", "%d %b", - { defaults => { - year => 2023, # Years >= 1000: actual year - hour => 14, # Years < 1000: offset from 1900 - min => 30 - } }); + my $t = Time::Piece->strptime("15 Mar", "%d %b", + { defaults => { + year => 2023, + hour => 14, + min => 30 + } }); Valid keys: C, C, C, C, C, C, C, C, C -B C in this context doesn't have to be an offset from 1900 +B: For the C parameter numbers less than 1000 are treated as an +offset from 1900. Whereas numbers larger than 1000 are treated as the actual year. -B<3. Time::Piece Object> - Copies all components including C: +B - Uses all components from the object: - my $base = localtime(); + my $base = localtime(); + my $t = Time::Piece->strptime("15 Mar", "%d %b", + { defaults => $base }); - my $t1 = Time::Piece->strptime("15 Mar", "%d %b", - { defaults => $base }); +B In all cases, parsed values always override defaults. Only missing +components use default values. - # Shorthand (equivalent) - my $t2 = Time::Piece->strptime("15 Mar", "%d %b", $base); +=head2 GMT vs Local Time -=head4 Format String Defaults +By default, C returns GMT objects when called as a class method: -When omitted, format defaults to C<"%a, %d %b %Y %H:%M:%S %Z">: + # Returns GMT (c_islocal = 0) + Time::Piece->strptime($string, $format) - # These are equivalent: - my $t1 = Time::Piece->strptime($string); - my $t2 = Time::Piece->strptime($string, "%a, %d %b %Y %H:%M:%S %Z"); +To get local time objects, you can: -=head3 Timezone Behavior + # Call as instance method on localtime object + localtime()->strptime($string, $format) -The returned object's timezone (C) depends on the calling context: + # Use explicit islocal option + Time::Piece->strptime($string, $format, { islocal => 1 }) -B (c_islocal = 0) + # Pass a local Time::Piece object as defaults + my $local = localtime(); + Time::Piece->strptime($string, $format, { defaults => $local }) - Time::Piece->strptime($string, $format) # Class method returns GMT +=head3 Locale Considerations -B (c_islocal = 1) via: +By default, C only parses English day and month names, while +C uses your system locale. This can cause parsing failures for +non-English dates. - # Instance method on localtime object - localtime()->strptime($string, $format) +To parse localized dates, call Cuse_locale()> to build +a list of your locale's day and month names: - # Explicit islocal option - Time::Piece->strptime($string, $format, { islocal => 1 }) + # Enable locale-aware parsing (global setting) + Time::Piece->use_locale(); - # Inherited from Time::Piece defaults - my $tp_obj = localtime(); - Time::Piece->strptime($string, $format, $tp_obj) + # Now strptime can parse names in your system locale + my $t = Time::Piece->strptime("15 Marzo 2024", "%d %B %Y"); -B Parsed values always override defaults. Only missing components use default values. +B This is a global change affecting all Time::Piece instances. -For more information see "man strptime" on unix systems. +You can also override the day/month names manually: -Alternatively look here: L + my @days = qw( Domingo Lunes Martes Miercoles Jueves Viernes Sabado ); + my $spanish_day = localtime->day(@days); -=head3 CAVEAT %A, %a, %B, %b, and friends + my @months = qw( Enero Febrero Marzo Abril Mayo Junio + Julio Agosto Septiembre Octubre Noviembre Diciembre ); + print localtime->month(@months); -Time::Piece::strptime by default can only parse American English date names. -Meanwhile, Time::Piece->strftime() will return date names that use the current -configured system locale. This means dates returned by strftime might not be -able to be parsed by strptime. This is the default behavior and can be -overridden by calling Time::Piece->use_locale(). This builds a list of the -current locale's day and month names which strptime will use to parse with. -Note this is a global override and will affect all Time::Piece instances. +Set globally with: -For instance with a German locale: + Time::Piece::day_list(@days); + Time::Piece::mon_list(@months); - localtime->day_list(); +=head2 Timezone Parsing with %z and %Z -Returns +Time::Piece's C function has some limited support for parsing timezone +information through two format specifiers: C<%z> and C<%Z> - ( 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' ) +Added in version 1.38. Prior to that, these flags were mostly ignored. +Consider the current implementation somewhat "alpha" and in need of feedback. -While: +=head3 Numeric Offsets (%z) - Time::Piece->use_locale(); - localtime->day_list(); +The C<%z> specifier parses numeric timezone offsets (format: C<+HHMM> or C<-HHMM>): + + my $t = Time::Piece->strptime("2024-01-15 15:30:00 +0500", + "%Y-%m-%d %H:%M:%S %z"); + print $t->hour; # prints 10 (converted to UTC: 15:30 - 5:00) + +Key behaviors: + +=over 4 + +=item * Offsets are applied to convert to UTC (C<+0500> means "5 hours ahead of UTC") + +=item * Valid range: C<-1200> to C<+1400> with minutes less than 60 + +=item * For local objects (C), the result is converted to system timezone -Returns +=back - ( 'So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa' ) +Times parsed with timezone information default to GMT. To convert to local time: -=head2 Global Overriding + # Parse and convert to local timezone + my $t = Time::Piece->strptime("2024-01-15 15:30:00 +0500", + "%Y-%m-%d %H:%M:%S %z", + { islocal => 1 }); + # Result: 10:30 UTC converted to your local timezone -Finally, it's possible to override localtime and gmtime everywhere, by -including the ':override' tag in the import list: +=head3 Timezone Names (%Z) + +The C<%Z> specifier currently only recognizes "GMT" and "UTC" (case-sensitive). +Other timezone names are parsed B: + + # GMT/UTC recognized and handled + my $t1 = Time::Piece->strptime("2024-01-15 10:30:00 GMT", + "%Y-%m-%d %H:%M:%S %Z"); + print $t1->hour; # prints 10 (no adjustment) + + # Other timezones parsed but ignored + my $t2 = Time::Piece->strptime("2024-01-15 10:30:00 PST", + "%Y-%m-%d %H:%M:%S %Z"); + print $t2->hour; # prints 10 (PST ignored - no adjustment) + +B Full timezone name support is not currently implemented. For reliable +timezone handling beyond GMT/UTC, consider using the L module. + +=head1 Global Overriding + +To override localtime and gmtime everywhere: use Time::Piece ':override'; +This replaces Perl's built-in functions with Time::Piece versions globally. + =head1 CAVEATS =head2 Setting $ENV{TZ} in Threads on Win32 @@ -1281,10 +1369,10 @@ interpreter maintains its own copy of the environment and only the main interpreter will update the process environment seen by strftime. Therefore, if you make changes to $ENV{TZ} from inside a thread other than -the main thread then those changes will not be seen by strftime if you +the main thread then those changes will not be seen by C if you subsequently call that with the %Z formatting code. You must change $ENV{TZ} in the main thread to have the desired effect in this case (and you must -also call _tzset() in the main thread to register the environment change). +also call C<_tzset()> in the main thread to register the environment change). Furthermore, remember that this caveat also applies to fork(), which is emulated by threads on Win32. @@ -1294,19 +1382,10 @@ emulated by threads on Win32. This module internally uses the epoch seconds system that is provided via the perl C function and supported by C and C. -If your perl does not support times larger than C<2^31> seconds then this -module is likely to fail at processing dates beyond the year 2038. There are -moves afoot to fix that in perl. Alternatively use 64 bit perl. Or if none -of those are options, use the L module which has support for years -well into the future and past. - -Also, the internal representation of Time::Piece->strftime deviates from the -standard POSIX implementation in that is uses the epoch (instead of separate -year, month, day parts). This change was added in version 1.30. If you must -have a more traditional strftime (which will normally never calculate day -light saving times correctly), you can pass the date parts from Time::Piece -into the strftime function provided by the POSIX module -(see strftime in L ). +If your perl does not support times larger than C<2^31> seconds +(Perl versions < 5.12) then this module is likely to fail at processing dates +beyond the year 2038. If that is not an option, use the L module +which has support for years well into the future and past. =head1 AUTHOR @@ -1326,6 +1405,12 @@ The excellent Calendar FAQ at L =head1 BUGS -The test harness leaves much to be desired. Patches welcome. +=over 4 + +=item * The test harness leaves much to be desired. Patches welcome. + +=item * Proper UTF8 support + +=back =cut diff --git a/Piece.xs b/Piece.xs index 1c38b88..593296d 100644 --- a/Piece.xs +++ b/Piece.xs @@ -19,6 +19,51 @@ #define WEEKDAY_BIAS 6 /* (1+6)%7 makes Sunday 0 again */ #define TP_BUF_SIZE 160 +# ifndef MIN +# define MIN(a,b) ((a) < (b) ? (a) : (b)) +# endif + +#ifdef HAVE_TIMEGM + +# define my_timegm timegm + +#elif defined(WIN32) + +# define my_timegm _mkgmtime + +#else +/* Fallback for platforms without timegm() (AIX, HP-UX, QNX, old Solaris) */ +/* Howard Hinnant's algorithm - public domain */ +static int days_from_civil(int y, int m, int d) { + y -= m <= 2; + const int era = (y >= 0 ? y : y-399) / 400; + const int yoe = y - era * 400; + const int doy = (153*(m + (m > 2 ? -3 : 9)) + 2)/5 + d-1; + const int doe = yoe * 365 + yoe/4 - yoe/100 + doy; + return era * 146097 + doe - 719468; +} + +static time_t my_timegm(struct tm *tm) { + int year = tm->tm_year + 1900; + int month = tm->tm_mon; + + /* Normalize month */ + if (month > 11) { + year += month / 12; + month %= 12; + } else if (month < 0) { + const int years_diff = (11 - month) / 12; + year -= years_diff; + month += 12 * years_diff; + } + + const int days_since_epoch = days_from_civil(year, month + 1, tm->tm_mday); + + return 60 * (60 * (24L * days_since_epoch + tm->tm_hour) + tm->tm_min) + tm->tm_sec; +} + +#endif + #ifdef WIN32 /* @@ -310,38 +355,17 @@ my_mini_mktime(struct tm *ptm) * official policies, either expressed or implied, of Powerdog Industries. */ -#include -#include -#include static char * _strptime(pTHX_ const char *, const char *, struct tm *, - int *got_GMT); - -#define asizeof(a) (sizeof (a) / sizeof ((a)[0])) - -struct lc_time_T { - char * mon[12]; - char * month[12]; - char * wday[7]; - char * weekday[7]; - char * am; - char * pm; - char * AM; - char * PM; - char * alt_month[12]; -}; + int *got_GMT, HV *locales); -static struct lc_time_T _C_time_locale; - -#define Locale (&_C_time_locale) - static char * -_strptime(pTHX_ const char *buf, const char *fmt, struct tm *tm, int *got_GMT) +_strptime(pTHX_ const char *buf, const char *fmt, struct tm *tm, int *got_GMT, HV *locales) { char c; const char *ptr; int i; - size_t len; + size_t len = 0; int Ealternative, Oalternative; /* There seems to be a slightly improved version at @@ -361,7 +385,7 @@ _strptime(pTHX_ const char *buf, const char *fmt, struct tm *tm, int *got_GMT) buf++; else if (c != *buf++) { warn("Time string mismatches format string"); - return 0; + return NULL; } continue; } @@ -374,18 +398,18 @@ label: case 0: case '%': if (*buf++ != '%') - return 0; + return NULL; break; case '+': - buf = _strptime(aTHX_ buf, "%c", tm, got_GMT); + buf = _strptime(aTHX_ buf, "%c", tm, got_GMT, locales); if (buf == 0) - return 0; + return NULL; break; case 'C': if (!isdigit((unsigned char)*buf)) - return 0; + return NULL; /* XXX This will break for 3-digit centuries. */ len = 2; @@ -395,7 +419,7 @@ label: len--; } if (i < 19) - return 0; + return NULL; tm->tm_year = i * 100 - 1900; break; @@ -403,15 +427,15 @@ label: case 'c': /* NOTE: c_fmt is intentionally ignored */ - buf = _strptime(aTHX_ buf, "%a %d %b %Y %I:%M:%S %p %Z", tm, got_GMT); + buf = _strptime(aTHX_ buf, "%a %d %b %Y %I:%M:%S %p %Z", tm, got_GMT, locales); if (buf == 0) - return 0; + return NULL; break; case 'D': - buf = _strptime(aTHX_ buf, "%m/%d/%y", tm, got_GMT); + buf = _strptime(aTHX_ buf, "%m/%d/%y", tm, got_GMT, locales); if (buf == 0) - return 0; + return NULL; break; case 'E': @@ -427,57 +451,59 @@ label: goto label; case 'F': - buf = _strptime(aTHX_ buf, "%Y-%m-%d", tm, got_GMT); + buf = _strptime(aTHX_ buf, "%Y-%m-%d", tm, got_GMT, locales); if (buf == 0) - return 0; + return NULL; break; case 'R': - buf = _strptime(aTHX_ buf, "%H:%M", tm, got_GMT); + buf = _strptime(aTHX_ buf, "%H:%M", tm, got_GMT, locales); if (buf == 0) - return 0; + return NULL; break; case 'r': - if (Locale->AM && strlen(Locale->AM) > 0 && - Locale->PM && strlen(Locale->PM) > 0) { - buf = _strptime(aTHX_ buf, "%I:%M:%S %p", tm, got_GMT); - } else { - buf = _strptime(aTHX_ buf, "%H:%M:%S", tm, got_GMT); + { + SV** am_sv = hv_fetchs(locales, "AM", 0); + if (am_sv && SvPOK(*am_sv) && SvCUR(*am_sv) > 0) { + buf = _strptime(aTHX_ buf, "%I:%M:%S %p", tm, got_GMT, locales); + } else { + buf = _strptime(aTHX_ buf, "%H:%M:%S", tm, got_GMT, locales); + } } if (buf == 0) - return 0; + return NULL; break; case 'n': /* whitespace */ case 't': if (!isspace((unsigned char)*buf)) - return 0; + return NULL; while (isspace((unsigned char)*buf)) buf++; break; case 'T': - buf = _strptime(aTHX_ buf, "%H:%M:%S", tm, got_GMT); + buf = _strptime(aTHX_ buf, "%H:%M:%S", tm, got_GMT, locales); if (buf == 0) - return 0; + return NULL; break; case 'X': - buf = _strptime(aTHX_ buf, "%I:%M:%S %p", tm, got_GMT); + buf = _strptime(aTHX_ buf, "%I:%M:%S %p", tm, got_GMT, locales); if (buf == 0) - return 0; + return NULL; break; case 'x': - buf = _strptime(aTHX_ buf, "%a %d %b %Y", tm, got_GMT); + buf = _strptime(aTHX_ buf, "%a %d %b %Y", tm, got_GMT, locales); if (buf == 0) - return 0; + return NULL; break; case 'j': if (!isdigit((unsigned char)*buf)) - return 0; + return NULL; len = 3; for (i = 0; len && *buf != 0 && isdigit((unsigned char)*buf); buf++) { @@ -486,7 +512,7 @@ label: len--; } if (i < 1 || i > 366) - return 0; + return NULL; tm->tm_yday = i - 1; tm->tm_mday = 0; @@ -498,7 +524,7 @@ label: break; if (!isdigit((unsigned char)*buf)) - return 0; + return NULL; len = 2; for (i = 0; len && *buf != 0 && isdigit((unsigned char)*buf); buf++) { @@ -509,11 +535,11 @@ label: if (c == 'M') { if (i > 59) - return 0; + return NULL; tm->tm_min = i; } else { if (i > 60) - return 0; + return NULL; tm->tm_sec = i; } @@ -535,7 +561,7 @@ label: * digits if used incorrectly. */ if (!isdigit((unsigned char)*buf)) - return 0; + return NULL; len = 2; for (i = 0; len && *buf != 0 && isdigit((unsigned char)*buf); buf++) { @@ -545,10 +571,10 @@ label: } if (c == 'H' || c == 'k') { if (i > 23) - return 0; + return NULL; } else if (i > 12) { warn("Hour cannot be >12 with %%I or %%l"); - return 0; + return NULL; } tm->tm_hour = i; @@ -564,58 +590,86 @@ label: * XXX This is bogus if parsed before hour-related * specifiers. */ - len = strlen(Locale->am); - if (strncasecmp(buf, Locale->am, len) == 0 || - strncasecmp(buf, Locale->AM, len) == 0) { - if (tm->tm_hour > 12) { - warn("Hour cannot be >12 with %%p"); - return 0; + { + SV** am_sv = hv_fetchs(locales, "am", 0); + SV** AM_sv = hv_fetchs(locales, "AM", 0); + if (am_sv && SvPOK(*am_sv) && AM_sv && SvPOK(*AM_sv)) { + char* am_str = SvPV_nolen(*am_sv); + char* AM_str = SvPV_nolen(*AM_sv); + len = MIN(strlen(am_str),strlen(AM_str)); + if ((strncasecmp(buf, am_str, len) == 0) || + strncasecmp(buf, AM_str, len) == 0) { + if (tm->tm_hour > 12) { + warn("Hour cannot be >12 with %%p"); + return NULL; + } + + if (tm->tm_hour == 12) + tm->tm_hour = 0; + buf += len; + break; + } } - if (tm->tm_hour == 12) - tm->tm_hour = 0; - buf += len; - break; - } - - len = strlen(Locale->pm); - if (strncasecmp(buf, Locale->pm, len) == 0 || - strncasecmp(buf, Locale->PM, len) == 0) { - if (tm->tm_hour > 12) { - warn("Hour cannot be >12 with %%p"); - return 0; + SV** pm_sv = hv_fetchs(locales, "pm", 0); + SV** PM_sv = hv_fetchs(locales, "PM", 0); + if (pm_sv && SvPOK(*pm_sv) && PM_sv && SvPOK(*PM_sv)) { + char* pm_str = SvPV_nolen(*pm_sv); + char* PM_str = SvPV_nolen(*PM_sv); + len = MIN(strlen(pm_str),strlen(PM_str)); + if ((strncasecmp(buf, pm_str, len) == 0) || + strncasecmp(buf, PM_str, len) == 0) { + if (tm->tm_hour > 12) { + warn("Hour cannot be >12 with %%p"); + return NULL; + } + if (tm->tm_hour != 12) + tm->tm_hour += 12; + buf += len; + break; + } } - if (tm->tm_hour != 12) - tm->tm_hour += 12; - buf += len; - break; } warn("Failed parsing %%p"); - return 0; + return NULL; case 'A': case 'a': - for (i = 0; i < (int)asizeof(Locale->weekday); i++) { + { + SV** weekday_sv = hv_fetchs(locales, "weekday", 0); + SV** wday_sv = hv_fetchs(locales, "wday", 0); + if (!weekday_sv || !wday_sv || !SvROK(*weekday_sv) || !SvROK(*wday_sv)) + return NULL; + + AV* weekday_av = (AV*)SvRV(*weekday_sv); + AV* wday_av = (AV*)SvRV(*wday_sv); + + for (i = 0; i <= av_len(weekday_av); i++) { if (c == 'A') { - len = strlen(Locale->weekday[i]); - if (strncasecmp(buf, - Locale->weekday[i], - len) == 0) - break; + SV** day_sv = av_fetch(weekday_av, i, 0); + if (day_sv && SvPOK(*day_sv)) { + char* day_str = SvPV(*day_sv, len); + if (strncasecmp(buf, day_str, len) == 0) + break; + } } else { - len = strlen(Locale->wday[i]); - if (strncasecmp(buf, - Locale->wday[i], - len) == 0) - break; + SV** day_sv = av_fetch(wday_av, i, 0); + if (day_sv && SvPOK(*day_sv)) { + char* day_str = SvPV(*day_sv, len); + if (strncasecmp(buf, day_str, len) == 0) + break; + } } } - if (i == (int)asizeof(Locale->weekday)) - return 0; + if (i > av_len(weekday_av)) { + warn("Failed parsing weekday names"); + return NULL; + } tm->tm_wday = i; buf += len; + } break; case 'U': @@ -628,7 +682,7 @@ label: * range for now. */ if (!isdigit((unsigned char)*buf)) - return 0; + return NULL; len = 2; for (i = 0; len && *buf != 0 && isdigit((unsigned char)*buf); buf++) { @@ -637,7 +691,7 @@ label: len--; } if (i > 53) - return 0; + return NULL; if (*buf != 0 && isspace((unsigned char)*buf)) while (*ptr != 0 && !isspace((unsigned char)*ptr)) @@ -647,11 +701,11 @@ label: case 'u': case 'w': if (!isdigit((unsigned char)*buf)) - return 0; + return NULL; i = *buf - '0'; if (i > 6 + (c == 'u')) - return 0; + return NULL; if (i == 7) i = 0; @@ -674,7 +728,7 @@ label: * digits if used incorrectly. */ if (!isdigit((unsigned char)*buf)) - return 0; + return NULL; len = 2; for (i = 0; len && *buf != 0 && isdigit((unsigned char)*buf); buf++) { @@ -683,7 +737,7 @@ label: len--; } if (i > 31) - return 0; + return NULL; tm->tm_mday = i; @@ -695,41 +749,46 @@ label: case 'B': case 'b': case 'h': - for (i = 0; i < (int)asizeof(Locale->month); i++) { - if (Oalternative) { - if (c == 'B') { - len = strlen(Locale->alt_month[i]); - if (strncasecmp(buf, - Locale->alt_month[i], - len) == 0) + { + SV** month_sv = hv_fetchs(locales, "month", 0); + SV** mon_sv = hv_fetchs(locales, "mon", 0); + if (!month_sv || !mon_sv || !SvROK(*month_sv) || !SvROK(*mon_sv)) + return NULL; + + AV* month_av = (AV*)SvRV(*month_sv); + AV* mon_av = (AV*)SvRV(*mon_sv); + + for (i = 0; i <= av_len(month_av); i++) { + + if (c == 'B') { + SV** month_sv_item = av_fetch(month_av, i, 0); + if (month_sv_item && SvPOK(*month_sv_item)) { + char* month_str = SvPV(*month_sv_item, len); + if (strncasecmp(buf, month_str, len) == 0) break; } } else { - if (c == 'B') { - len = strlen(Locale->month[i]); - if (strncasecmp(buf, - Locale->month[i], - len) == 0) - break; - } else { - len = strlen(Locale->mon[i]); - if (strncasecmp(buf, - Locale->mon[i], - len) == 0) + SV** mon_sv_item = av_fetch(mon_av, i, 0); + if (mon_sv_item && SvPOK(*mon_sv_item)) { + char* mon_str = SvPV(*mon_sv_item, len); + if (strncasecmp(buf, mon_str, len) == 0) break; } } } - if (i == (int)asizeof(Locale->month)) - return 0; + if (i > av_len(month_av)) { + warn("Failed parsing month name"); + return NULL; + } tm->tm_mon = i; buf += len; + } break; case 'm': if (!isdigit((unsigned char)*buf)) - return 0; + return NULL; len = 2; for (i = 0; len && *buf != 0 && isdigit((unsigned char)*buf); buf++) { @@ -738,7 +797,7 @@ label: len--; } if (i < 1 || i > 12) - return 0; + return NULL; tm->tm_mon = i - 1; @@ -757,19 +816,17 @@ label: sverrno = errno; errno = 0; - n = strtol(buf, &cp, 10); + n = Strtol(buf, &cp, 10); if (errno == ERANGE || (long)(t = n) != n) { errno = sverrno; - return 0; + return NULL; } errno = sverrno; buf = cp; - memset(&mytm, 0, sizeof(mytm)); + Zero(&mytm, 1, struct tm); - if(*got_GMT == 1) - mytm = *localtime(&t); - else - mytm = *gmtime(&t); + mytm = *gmtime(&t); + *got_GMT = 1; tm->tm_sec = mytm.tm_sec; tm->tm_min = mytm.tm_min; @@ -789,7 +846,7 @@ label: break; if (!isdigit((unsigned char)*buf)) - return 0; + return NULL; len = (c == 'Y') ? 4 : 2; for (i = 0; len && *buf != 0 && isdigit((unsigned char)*buf); buf++) { @@ -802,7 +859,7 @@ label: if (c == 'y' && i < 69) i += 100; if (i < 0) - return 0; + return NULL; tm->tm_year = i; @@ -819,19 +876,18 @@ label: for (cp = buf; *cp && isupper((unsigned char)*cp); ++cp) {/*empty*/} if (cp - buf) { - zonestr = (char *)malloc((size_t) (cp - buf + 1)); + zonestr = (char *)safemalloc((size_t) (cp - buf + 1)); if (!zonestr) { + Safefree(zonestr); errno = ENOMEM; - return 0; + return NULL; } - strncpy(zonestr, buf,(size_t) (cp - buf)); - zonestr[cp - buf] = '\0'; - my_tzset(aTHX); - if (0 == strcmp(zonestr, "GMT")) { + my_strlcpy(zonestr, buf,(size_t) (cp - buf)+1); + /* my_tzset(aTHX); */ + if (strEQ(zonestr, "GMT") || strEQ(zonestr, "UTC")) { *got_GMT = 1; } - free(zonestr); - if (!*got_GMT) return 0; + Safefree(zonestr); buf += cp - buf; } } @@ -845,7 +901,7 @@ label: if (*buf == '-') sign = -1; else - return 0; + return NULL; } buf++; @@ -855,10 +911,17 @@ label: i *= 10; i += *buf - '0'; buf++; + } else if (len == 2) { + i *= 100; + break; } else - return 0; + return NULL; } + /* Valid if between UTC+14 and UTC-12 and minutes <= 60 */ + if (i > 1400 || (sign == -1 && i > 1200) || (i % 100) >= 60) + return NULL; + tm->tm_hour -= sign * (i / 100); tm->tm_min -= sign * (i % 100); *got_GMT = 1; @@ -927,34 +990,6 @@ return_11part_tm(pTHX_ SV ** SP, struct tm *mytm) } -static void _populate_C_time_locale(pTHX_ HV* locales ) -{ - AV* alt_names = (AV *) SvRV( *hv_fetch(locales, "alt_month", 9, 0) ); - AV* long_names = (AV *) SvRV( *hv_fetch(locales, "month", 5, 0) ); - AV* short_names = (AV *) SvRV( *hv_fetch(locales, "mon", 3, 0) ); - int i; - - for (i = 0; i < 1 + (int) av_len( long_names ); i++) { - Locale->alt_month[i] = SvPV_nolen( (SV *) *av_fetch(alt_names, i, 0) ); - Locale->month[i] = SvPV_nolen( (SV *) *av_fetch(long_names, i, 0) ); - Locale->mon[i] = SvPV_nolen( (SV *) *av_fetch(short_names, i, 0) ); - } - - long_names = (AV *) SvRV( *hv_fetch(locales, "weekday", 7, 0) ); - short_names = (AV *) SvRV( *hv_fetch(locales, "wday", 4, 0) ); - - for (i = 0; i < 1 + (int) av_len( long_names ); i++) { - Locale->wday[i] = SvPV_nolen( (SV *) *av_fetch(short_names, i, 0) ); - Locale->weekday[i] = SvPV_nolen( (SV *) *av_fetch(long_names, i, 0) ); - } - - Locale->am = SvPV_nolen( (SV *) *hv_fetch(locales, "am", 2, 0) ); - Locale->pm = SvPV_nolen( (SV *) *hv_fetch(locales, "pm", 2, 0) ); - Locale->AM = SvPV_nolen( (SV *) *hv_fetch(locales, "AM", 2, 0) ); - Locale->PM = SvPV_nolen( (SV *) *hv_fetch(locales, "PM", 2, 0) ); - - return; -} MODULE = Time::Piece PACKAGE = Time::Piece @@ -1031,19 +1066,20 @@ _tzset() return; /* skip XSUBPP's PUTBACK */ void -_strptime ( string, format, got_GMT, localization, defaults_ref ) +_strptime ( string, format, islocal, localization, defaults_ref ) char * string char * format - int got_GMT + int islocal SV * localization SV * defaults_ref PREINIT: struct tm mytm; + int got_GMT = 0; char * remainder; HV * locales; AV * defaults_av; PPCODE: - memset(&mytm, 0, sizeof(mytm)); + Zero(&mytm, 1, struct tm); /* sensible defaults. */ mytm.tm_mday = 1; @@ -1058,9 +1094,6 @@ _strptime ( string, format, got_GMT, localization, defaults_ref ) croak("_strptime requires a Hash Reference of locales"); } - /* populate our locale data struct (used for %[AaBbPp] flags) */ - _populate_C_time_locale(aTHX_ locales ); - /* Check if defaults array was passed and apply them now */ if (SvOK(defaults_ref) && SvROK(defaults_ref) && SvTYPE(SvRV(defaults_ref)) == SVt_PVAV) { defaults_av = (AV*)SvRV(defaults_ref); @@ -1068,25 +1101,25 @@ _strptime ( string, format, got_GMT, localization, defaults_ref ) SV** elem; elem = av_fetch(defaults_av, 0, 0); - if (elem && SvOK(*elem)) mytm.tm_sec = SvIV(*elem); + if (elem && SvOK(*elem)) mytm.tm_sec = (int)SvIV(*elem); elem = av_fetch(defaults_av, 1, 0); - if (elem && SvOK(*elem)) mytm.tm_min = SvIV(*elem); + if (elem && SvOK(*elem)) mytm.tm_min = (int)SvIV(*elem); elem = av_fetch(defaults_av, 2, 0); - if (elem && SvOK(*elem)) mytm.tm_hour = SvIV(*elem); + if (elem && SvOK(*elem)) mytm.tm_hour = (int)SvIV(*elem); elem = av_fetch(defaults_av, 3, 0); - if (elem && SvOK(*elem)) mytm.tm_mday = SvIV(*elem); + if (elem && SvOK(*elem)) mytm.tm_mday = (int)SvIV(*elem); elem = av_fetch(defaults_av, 4, 0); - if (elem && SvOK(*elem)) mytm.tm_mon = SvIV(*elem); + if (elem && SvOK(*elem)) mytm.tm_mon = (int)SvIV(*elem); elem = av_fetch(defaults_av, 5, 0); - if (elem && SvOK(*elem)) mytm.tm_year = SvIV(*elem); + if (elem && SvOK(*elem)) mytm.tm_year = (int)SvIV(*elem); elem = av_fetch(defaults_av, 6, 0); - if (elem && SvOK(*elem)) mytm.tm_wday = SvIV(*elem); + if (elem && SvOK(*elem)) mytm.tm_wday = (int)SvIV(*elem); elem = av_fetch(defaults_av, 7, 0); - if (elem && SvOK(*elem)) mytm.tm_yday = SvIV(*elem); + if (elem && SvOK(*elem)) mytm.tm_yday = (int)SvIV(*elem); } } - remainder = (char *)_strptime(aTHX_ string, format, &mytm, &got_GMT); + remainder = (char *)_strptime(aTHX_ string, format, &mytm, &got_GMT, locales); if (remainder == NULL) { croak("Error parsing time"); } @@ -1095,6 +1128,13 @@ _strptime ( string, format, got_GMT, localization, defaults_ref ) warn("Perhaps a format flag did not match the actual input?"); } + /* convert if we have a tm in GMT but were called from a localized object */ + if (got_GMT == 1 && islocal == 1) { + time_t t; + t = my_timegm(&mytm); + mytm = *localtime(&t); + } + return_11part_tm(aTHX_ SP, &mytm); return; @@ -1151,7 +1191,6 @@ _get_localization() AV* weekdays = newAV(); AV* mons = newAV(); AV* months = newAV(); - SV** tmp; size_t len; char buf[TP_BUF_SIZE]; size_t i; @@ -1181,26 +1220,25 @@ _get_localization() ++mytm.tm_mon; } - tmp = hv_store(locales, "wday", 4, newRV_noinc((SV *) wdays), 0); - tmp = hv_store(locales, "weekday", 7, newRV_noinc((SV *) weekdays), 0); - tmp = hv_store(locales, "mon", 3, newRV_noinc((SV *) mons), 0); - tmp = hv_store(locales, "month", 5, newRV_noinc((SV *) months), 0); - tmp = hv_store(locales, "alt_month", 9, newRV((SV *) months), 0); + hv_stores(locales, "wday", newRV_noinc((SV *) wdays)); + hv_stores(locales, "weekday", newRV_noinc((SV *) weekdays)); + hv_stores(locales, "mon", newRV_noinc((SV *) mons)); + hv_stores(locales, "month", newRV_noinc((SV *) months)); + len = strftime(buf, TP_BUF_SIZE, "%p", &mytm); - tmp = hv_store(locales, "AM", 2, newSVpvn(buf,len), 0); + hv_stores(locales, "AM", newSVpvn(buf,len)); +# ifndef WIN32 len = strftime(buf, TP_BUF_SIZE, "%P", &mytm); - tmp = hv_store(locales, "am", 2, newSVpvn(buf,len), 0); + hv_stores(locales, "am", newSVpvn(buf,len)); +# endif mytm.tm_hour = 18; len = strftime(buf, TP_BUF_SIZE, "%p", &mytm); - tmp = hv_store(locales, "PM", 2, newSVpvn(buf,len), 0); + hv_stores(locales, "PM", newSVpvn(buf,len)); +# ifndef WIN32 len = strftime(buf, TP_BUF_SIZE, "%P", &mytm); - tmp = hv_store(locales, "pm", 2, newSVpvn(buf,len), 0); - - if(tmp == NULL || !SvOK( (SV *) *tmp)){ - croak("Failed to get localization."); - } - + hv_stores(locales, "pm", newSVpvn(buf,len)); +# endif RETVAL = newRV_noinc((SV *)locales); OUTPUT: RETVAL diff --git a/README.md b/README.md index 3a8b3ad..d0955c5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Time::Piece - Object Oriented time objects # SYNOPSIS use Time::Piece; - + my $t = localtime; print "Time is $t\n"; print "Year is ", $t->year, "\n"; @@ -29,41 +29,52 @@ The module actually implements most of an interface described by Larry Wall on the perl5-porters mailing list here: [https://www.nntp.perl.org/group/perl.perl5.porters/2000/01/msg5283.html](https://www.nntp.perl.org/group/perl.perl5.porters/2000/01/msg5283.html) -# USAGE - -After importing this module, when you use localtime or gmtime in a scalar +After importing this module, when you use `localtime` or `gmtime` in a scalar context, rather than getting an ordinary scalar string representing the -date and time, you get a Time::Piece object, whose stringification happens -to produce the same effect as the localtime and gmtime functions. There is -also a new() constructor provided, which is the same as localtime(), except -when passed a Time::Piece object, in which case it's a copy constructor. The -following methods are available on the object: +date and time, you get a `Time::Piece` object, whose stringification happens +to produce the same effect as the `localtime` and `gmtime` functions. + +The primary way to create Time::Piece objects is through the `localtime` and +`gmtime` functions. There is also a `new()` constructor which is the same as +`localtime()`, except when passed a Time::Piece object, in which case it's a +copy constructor. + +# Public Methods + +The following methods are available on the object: + +## Time Components $t->sec # also available as $t->second $t->min # also available as $t->minute $t->hour # 24 hour + +## Date Components + $t->mday # also available as $t->day_of_month $t->mon # 1 = January $t->_mon # 0 = January - $t->monname # Feb - $t->month # same as $t->monname - $t->fullmonth # February $t->year # based at 0 (year 0 AD is, of course 1 BC) $t->_year # year minus 1900 $t->yy # 2 digit year + +## Day and Month Names + + $t->monname # Feb + $t->month # same as $t->monname + $t->fullmonth # February $t->wday # 1 = Sunday $t->_wday # 0 = Sunday $t->day_of_week # 0 = Sunday $t->wdayname # Tue $t->day # same as wdayname $t->fullday # Tuesday - $t->yday # also available as $t->day_of_year, 0 = Jan 01 - $t->isdst # also available as $t->daylight_savings + +## Formatted Date/Time Output $t->hms # 12:34:56 $t->hms(".") # 12.34.56 $t->time # same as $t->hms - $t->ymd # 2000-02-29 $t->date # same as $t->ymd $t->mdy # 02-29-2000 @@ -73,69 +84,62 @@ following methods are available on the object: $t->datetime # 2000-02-29T12:34:56 (ISO 8601) $t->cdate # Tue Feb 29 12:34:56 2000 "$t" # same as $t->cdate + $t->strftime(FORMAT) # same as POSIX::strftime (without the overhead + # of the full POSIX extension) + $t->strftime() # "Tue, 29 Feb 2000 12:34:56 GMT" - $t->epoch # seconds since the epoch - $t->tzoffset # timezone offset in a Time::Seconds object +## Epoch and Calendar Calculations + $t->epoch # seconds since the epoch $t->julian_day # number of days since Julian period began $t->mjd # modified Julian date (JD-2400000.5 days) - $t->week # week number (ISO 8601) + $t->yday # also available as $t->day_of_year, 0 = Jan 01 + +## Timezone and DST + + $t->tzoffset # timezone offset in a Time::Seconds object + $t->isdst # also available as $t->daylight_savings + +The `isdst` method returns: + +- 0 for GMT/UTC times (they never have DST) +- 0 or 1 for local times depending on whether DST is active +- Automatically calculated if unknown + +The `tzoffset` method returns the offset from UTC as a Time::Seconds object. +For GMT/UTC times, this always returns 0. For local times, it calculates +the actual offset including any DST adjustment. + +## Utility Methods $t->is_leap_year # true if it's a leap year $t->month_last_day # 28-31 +## Global Configuration + $t->time_separator($s) # set the default separator (default ":") $t->date_separator($s) # set the default separator (default "-") $t->day_list(@days) # set the default weekdays $t->mon_list(@days) # set the default months - $t->strftime(FORMAT) # same as POSIX::strftime (without the overhead - # of the full POSIX extension) - $t->strftime() # "Tue, 29 Feb 2000 12:34:56 GMT" - +## Parsing + Time::Piece->strptime(STRING, FORMAT) # see strptime man page. Creates a new # Time::Piece object -Note that `localtime` and `gmtime` are not listed above. If called as +**Note:** `localtime` and `gmtime` are not listed above. If called as methods on a Time::Piece object, they act as constructors, returning a new -Time::Piece object for the current time. In other words: they're not useful as +Time::Piece object for the current time. In other words: they're not useful as methods. -## Local Locales - -Both `wdayname` (day) and `monname` (month) allow passing in a list to use -to index the name of the days against. This can be useful if you need -to implement some form of localisation without actually installing or -using locales. Note that this is a global override and will affect -all Time::Piece instances. - - my @days = qw( Dimanche Lundi Merdi Mercredi Jeudi Vendredi Samedi ); - - my $french_day = localtime->day(@days); - -These settings can be overridden globally too: - - Time::Piece::day_list(@days); - -Or for months: - - Time::Piece::mon_list(@months); - -And locally for months: - - print localtime->month(@months); - -Or to populate with your current system locale call: - Time::Piece->use\_locale(); - -## Date Calculations +# Date Calculations It's possible to use simple addition and subtraction of objects: use Time::Seconds; - + my $seconds = $t1 - $t2; $t1 += ONE_DAY; # add 1 day (constant from Time::Seconds) @@ -145,29 +149,31 @@ The following are valid ($t1 and $t2 are Time::Piece objects): $t1 - 42; # returns Time::Piece object $t1 + 533; # returns Time::Piece object -However adding a Time::Piece object to another Time::Piece object -will cause a runtime error. +**Note:** All arithmetic uses epoch seconds (UTC). When daylight saving time +(DST) changes occur: + +- Adding seconds works on UTC time, so adding 3600 seconds during DST +transition from 1:30 AM gives 3:30 AM (not 2:30 AM, which doesn't exist +during "spring forward") +- Subtracting across DST transitions may differ from wall-clock expectations +due to skipped or repeated hours -Note that the first of the above returns a Time::Seconds object, so -while examining the object will print the number of seconds (because -of the overloading), you can also get the number of minutes, hours, -days, weeks and years in that delta, using the Time::Seconds API. +## Adding Months and Years -In addition to adding seconds, there are two APIs for adding months and -years: +Two methods handle calendar arithmetic differently than seconds-based math: $t = $t->add_months(6); $t = $t->add_years(5); -The months and years can be negative for subtractions. Note that there -is some "strange" behaviour when adding and subtracting months at the -ends of months. Generally when the resulting month is shorter than the -starting month then the number of overlap days is added. For example -subtracting a month from 2008-03-31 will not result in 2008-02-31 as this -is an impossible date. Instead you will get 2008-03-02. This appears to -be consistent with other date manipulation tools. +**Important behaviors:** + +- These preserve the day-of-month number, which can cause overflow (Jan 31 + 1 +month = Mar 3, since "Feb 31" doesn't exist) +- Wall-clock time is preserved across DST transitions +- Order matters: `add_months(1)` then `+ 86400` gives different results than +`+ 86400` then `add_months(1)` -## Truncation +# Truncation Calling the `truncate` method returns a copy of the object but with the time truncated to the start of the supplied unit. @@ -178,16 +184,35 @@ This example will set the time to midnight on the same date which `$t` had previously. Allowed values for the "to" parameter are: "year", "quarter", "month", "day", "hour", "minute" and "second". -## Date Comparisons +# Date Comparisons Date comparisons are also possible, using the full suite of "<", ">", "<=", ">=", "<=>", "==" and "!=". -## Date Parsing +All comparisons use epoch seconds, so they work correctly across timezones: + + my $t1 = localtime; + my $t2 = gmtime; + if ($t1 > $t2) { # Compares actual moments in time, not clock values + # ... + } + +Time::Piece objects can also be compared as strings using `cmp`: + + if ($t1 cmp "2024-01-15") { # Compares against cdate format + # ... + } -Time::Piece provides flexible date parsing via the built-in strptime() function (from FreeBSD). +# Date Parsing -### Basic Usage +Time::Piece provides flexible date parsing via the built-in `strptime()` +function (from FreeBSD). + +For more information on acceptible formats and flags for `strptime` see +"man strptime" on unix systems. Alternatively look here: +[http://www.unix.com/man-page/FreeBSD/3/strftime/](http://www.unix.com/man-page/FreeBSD/3/strftime/) + +## Basic Usage my $t = Time::Piece->strptime("Sunday 3rd Nov, 1943", "%A %drd %b, %Y"); @@ -198,111 +223,151 @@ Outputs: Wed, 03 Nov 1943 -(see, it's even smart enough to fix my obvious date bug) +The default format string is `"%a, %d %b %Y %H:%M:%S %Z"`, so these are equivalent: -### Default Values for Partial Dates + my $t1 = Time::Piece->strptime($string); + my $t2 = Time::Piece->strptime($string, "%a, %d %b %Y %H:%M:%S %Z"); -When parsing incomplete date strings, you can provide defaults for missing components: +## Handling Partial Dates -#### Supported Default Types +When parsing incomplete date strings, you can provide defaults for missing +components in several ways: -**1. Array Reference** - Standard time components (sec, min, hour, mday, mon, year, wday, yday) (see `perldoc -f localtime`): +**Array Reference** - Standard time components (as returned by localtime): my @defaults = localtime(); my $t = Time::Piece->strptime("15 Mar", "%d %b", { defaults => \@defaults }); -**2. Hash Reference** - Specify only needed components: +**Hash Reference** - Specify only needed components: my $t = Time::Piece->strptime("15 Mar", "%d %b", { defaults => { - year => 2023, # Years >= 1000: actual year - hour => 14, # Years < 1000: offset from 1900 + year => 2023, + hour => 14, min => 30 } }); Valid keys: `sec`, `min`, `hour`, `mday`, `mon`, `year`, `wday`, `yday`, `isdst` -**Note:** `year` in this context doesn't have to be an offset from 1900 +**Note**: For the `year` parameter numbers less than 1000 are treated as an +offset from 1900. Whereas numbers larger than 1000 are treated as the actual year. -**3. Time::Piece Object** - Copies all components including `c_islocal`: +**Time::Piece Object** - Uses all components from the object: my $base = localtime(); + my $t = Time::Piece->strptime("15 Mar", "%d %b", + { defaults => $base }); - my $t1 = Time::Piece->strptime("15 Mar", "%d %b", - { defaults => $base }); +**Note:** In all cases, parsed values always override defaults. Only missing +components use default values. - # Shorthand (equivalent) - my $t2 = Time::Piece->strptime("15 Mar", "%d %b", $base); +## GMT vs Local Time -#### Format String Defaults +By default, `strptime` returns GMT objects when called as a class method: -When omitted, format defaults to `"%a, %d %b %Y %H:%M:%S %Z"`: + # Returns GMT (c_islocal = 0) + Time::Piece->strptime($string, $format) - # These are equivalent: - my $t1 = Time::Piece->strptime($string); - my $t2 = Time::Piece->strptime($string, "%a, %d %b %Y %H:%M:%S %Z"); +To get local time objects, you can: -### Timezone Behavior + # Call as instance method on localtime object + localtime()->strptime($string, $format) -The returned object's timezone (`c_islocal`) depends on the calling context: + # Use explicit islocal option + Time::Piece->strptime($string, $format, { islocal => 1 }) -**Default: GMT/UTC** (c\_islocal = 0) + # Pass a local Time::Piece object as defaults + my $local = localtime(); + Time::Piece->strptime($string, $format, { defaults => $local }) - Time::Piece->strptime($string, $format) # Class method returns GMT +### Locale Considerations -**Local Time** (c\_islocal = 1) via: +By default, `strptime` only parses English day and month names, while +`strftime` uses your system locale. This can cause parsing failures for +non-English dates. - # Instance method on localtime object - localtime()->strptime($string, $format) +To parse localized dates, call `Time::Piece->use_locale()` to build +a list of your locale's day and month names: - # Explicit islocal option - Time::Piece->strptime($string, $format, { islocal => 1 }) + # Enable locale-aware parsing (global setting) + Time::Piece->use_locale(); - # Inherited from Time::Piece defaults - my $tp_obj = localtime(); - Time::Piece->strptime($string, $format, $tp_obj) + # Now strptime can parse names in your system locale + my $t = Time::Piece->strptime("15 Marzo 2024", "%d %B %Y"); -**Note:** Parsed values always override defaults. Only missing components use default values. +**Note:** This is a global change affecting all Time::Piece instances. -For more information see "man strptime" on unix systems. +You can also override the day/month names manually: -Alternatively look here: [http://www.unix.com/man-page/FreeBSD/3/strftime/](http://www.unix.com/man-page/FreeBSD/3/strftime/) + my @days = qw( Domingo Lunes Martes Miercoles Jueves Viernes Sabado ); + my $spanish_day = localtime->day(@days); -### CAVEAT %A, %a, %B, %b, and friends + my @months = qw( Enero Febrero Marzo Abril Mayo Junio + Julio Agosto Septiembre Octubre Noviembre Diciembre ); + print localtime->month(@months); -Time::Piece::strptime by default can only parse American English date names. -Meanwhile, Time::Piece->strftime() will return date names that use the current -configured system locale. This means dates returned by strftime might not be -able to be parsed by strptime. This is the default behavior and can be -overridden by calling Time::Piece->use\_locale(). This builds a list of the -current locale's day and month names which strptime will use to parse with. -Note this is a global override and will affect all Time::Piece instances. +Set globally with: -For instance with a German locale: + Time::Piece::day_list(@days); + Time::Piece::mon_list(@months); - localtime->day_list(); +## Timezone Parsing with %z and %Z -Returns +Time::Piece's `strptime()` function has some limited support for parsing timezone +information through two format specifiers: `%z` and `%Z` - ( 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' ) +Added in version 1.38. Prior to that, these flags were mostly ignored. +Consider the current implementation somewhat "alpha" and in need of feedback. -While: +### Numeric Offsets (%z) - Time::Piece->use_locale(); - localtime->day_list(); +The `%z` specifier parses numeric timezone offsets (format: `+HHMM` or `-HHMM`): -Returns + my $t = Time::Piece->strptime("2024-01-15 15:30:00 +0500", + "%Y-%m-%d %H:%M:%S %z"); + print $t->hour; # prints 10 (converted to UTC: 15:30 - 5:00) - ( 'So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa' ) +Key behaviors: -## Global Overriding +- Offsets are applied to convert to UTC (`+0500` means "5 hours ahead of UTC") +- Valid range: `-1200` to `+1400` with minutes less than 60 +- For local objects (`islocal == 1`), the result is converted to system timezone -Finally, it's possible to override localtime and gmtime everywhere, by -including the ':override' tag in the import list: +Times parsed with timezone information default to GMT. To convert to local time: + + # Parse and convert to local timezone + my $t = Time::Piece->strptime("2024-01-15 15:30:00 +0500", + "%Y-%m-%d %H:%M:%S %z", + { islocal => 1 }); + # Result: 10:30 UTC converted to your local timezone + +### Timezone Names (%Z) + +The `%Z` specifier currently only recognizes "GMT" and "UTC" (case-sensitive). +Other timezone names are parsed **but ignored**: + + # GMT/UTC recognized and handled + my $t1 = Time::Piece->strptime("2024-01-15 10:30:00 GMT", + "%Y-%m-%d %H:%M:%S %Z"); + print $t1->hour; # prints 10 (no adjustment) + + # Other timezones parsed but ignored + my $t2 = Time::Piece->strptime("2024-01-15 10:30:00 PST", + "%Y-%m-%d %H:%M:%S %Z"); + print $t2->hour; # prints 10 (PST ignored - no adjustment) + +**Note:** Full timezone name support is not currently implemented. For reliable +timezone handling beyond GMT/UTC, consider using the [DateTime](https://metacpan.org/pod/DateTime) module. + +# Global Overriding + +To override localtime and gmtime everywhere: use Time::Piece ':override'; +This replaces Perl's built-in functions with Time::Piece versions globally. + # CAVEATS ## Setting $ENV{TZ} in Threads on Win32 @@ -313,10 +378,10 @@ interpreter maintains its own copy of the environment and only the main interpreter will update the process environment seen by strftime. Therefore, if you make changes to $ENV{TZ} from inside a thread other than -the main thread then those changes will not be seen by strftime if you +the main thread then those changes will not be seen by `strftime` if you subsequently call that with the %Z formatting code. You must change $ENV{TZ} in the main thread to have the desired effect in this case (and you must -also call \_tzset() in the main thread to register the environment change). +also call `_tzset()` in the main thread to register the environment change). Furthermore, remember that this caveat also applies to fork(), which is emulated by threads on Win32. @@ -326,19 +391,10 @@ emulated by threads on Win32. This module internally uses the epoch seconds system that is provided via the perl `time()` function and supported by `gmtime()` and `localtime()`. -If your perl does not support times larger than `2^31` seconds then this -module is likely to fail at processing dates beyond the year 2038. There are -moves afoot to fix that in perl. Alternatively use 64 bit perl. Or if none -of those are options, use the [DateTime](https://metacpan.org/pod/DateTime) module which has support for years -well into the future and past. - -Also, the internal representation of Time::Piece->strftime deviates from the -standard POSIX implementation in that is uses the epoch (instead of separate -year, month, day parts). This change was added in version 1.30. If you must -have a more traditional strftime (which will normally never calculate day -light saving times correctly), you can pass the date parts from Time::Piece -into the strftime function provided by the POSIX module -(see strftime in [POSIX](https://metacpan.org/pod/POSIX) ). +If your perl does not support times larger than `2^31` seconds +(Perl versions < 5.12) then this module is likely to fail at processing dates +beyond the year 2038. If that is not an option, use the [DateTime](https://metacpan.org/pod/DateTime) module +which has support for years well into the future and past. # AUTHOR @@ -358,7 +414,8 @@ The excellent Calendar FAQ at [http://www.tondering.dk/claus/calendar.html](http # BUGS -The test harness leaves much to be desired. Patches welcome. +- The test harness leaves much to be desired. Patches welcome. +- Proper UTF8 support ## Development Instructions diff --git a/Seconds.pm b/Seconds.pm index bbe2607..0e1089e 100644 --- a/Seconds.pm +++ b/Seconds.pm @@ -1,7 +1,7 @@ package Time::Seconds; use strict; -our $VERSION = '1.3701'; +our $VERSION = '1.38'; use Exporter 5.57 'import'; diff --git a/rev_deps.pl b/rev_deps.pl index fdc5f0d..ac4d27c 100644 --- a/rev_deps.pl +++ b/rev_deps.pl @@ -7,17 +7,20 @@ use MetaCPAN::Client; #search cpan for any module that mentions "Time::Piece" - +$|=1; my $i = 1; my @total_dists = (); my @skipped = (); my $too_old = localtime->strptime( "2011-01-01", "%Y-%m-%d" ); my $meta = MetaCPAN::Client->new(); +HTTP::Tiny->new->get("https://grep.metacpan.org/api/search?size=20&q=Time::Piece&qft=*.pm,%20*.t&qd=&qifl=&qls=on&p=1"); +sleep(10); + while ( my $response = HTTP::Tiny->new->get( - "https://grep.metacpan.org/api/search?p=$i&q=Time%3A%3APiece&qls=on") + "https://grep.metacpan.org/api/search?size=20&q=Time::Piece&qft=*.pm,%20*.t&qd=&qifl=&qls=on&p=$i") ) { ++$i; @@ -35,45 +38,37 @@ last; } - print "Got " . scalar @{ $j->{results} } . "\n"; foreach my $raw_dist ( @{ $j->{results} } ) { my $dist_date; my $release; + + #skip bundled stuff + if ( $raw_dist->{distro} =~ /task|belike|bundle/i ) { + print "SKIP: $raw_dist->{distro}\n"; + next; + } + eval { $dist_date = localtime->strptime( $meta->release( $raw_dist->{distro} )->date(), "%Y-%m-%dT%H:%M:%S" ); }; if($@){ - push( @skipped, "$raw_dist->{distro} -" . (split(/\n/, $@))[0] ); + print "SKIP: $raw_dist->{distro} -" . (split(/\n/, $@))[0]; + print "\n"; next; } - #skip bundled stuff - if ( $raw_dist->{distro} =~ /task|belike/i ) { - push( @skipped, $raw_dist->{distro} ); - next; - } #or too old - if ( $dist_date < $too_old ) { - push( @skipped, "$raw_dist->{distro} - $dist_date" ); + if ( $dist_date < $too_old && $raw_dist->{distro} !~ /time|date/i) { + print "SKIP (old): $raw_dist->{distro} - $dist_date\n"; next; } $raw_dist->{distro} =~ s/-/::/g; - push( @total_dists, $raw_dist->{distro} ); + print "$raw_dist->{distro}\n"; } - print "total_dists size is now: " . scalar @total_dists . "\n"; - print "Fetching page #$i\n"; + sleep(5); } -print "\n\nSkipped dists:\n\n"; -foreach my $dist (@skipped) { - print "$dist\n"; -} - -print "\n\nDep dists:\n\n"; -foreach my $dist (@total_dists) { - print "$dist\n"; -} diff --git a/reverse_deps.txt b/reverse_deps.txt index 84351aa..fd65744 100644 --- a/reverse_deps.txt +++ b/reverse_deps.txt @@ -1,151 +1,162 @@ -#These modules depend on Time::Piece. +#These modules depend on or use Time::Piece and have releases +#after 2010 and have some semblance of being maintained #You should probably make sure you don't break their #test cases since they do no telling what with Time::Piece. #generated by App::ListRevDeps and rev_deps.pl #needed libs for compiling: -#libxml2-dev libgd-dev uuid-dev libmariadb-dev-compat +#libxml2-dev libgd-dev uuid-dev libmariadb-dev-compat libexpat1-dev #and don't forget PERL_USE_UNSAFE_INC -Acme::CPANAuthors::Japanese -#Acme::Hidek #fails on perl 5.26+ +#Acme::Glue +Acme::Hidek Acme::Honkidasu +Acme::MilkyHolmes Acme::PrettyCure -Acme::ReturnValue +#Acme::ReturnValue Acme::Songmu -Ado Algorithm::Diff::HTMLTable Amazon::SES -Amon2 +#Amon2 AnyEvent::Twitter -#Apache::AxKit::Provider::XMLDOMProvider requires mod_perl: -App::authkeymgr App::BarnesNoble::WishListMinder -App::BigQuery::Importer::MySQL -#App::BoolFindGrep random failing tests -#App::Chart #bunch of failing deps +#App::BoolFindGrep App::CPAN::Fresh +#App::Chart +App::DBBrowser App::Dochazka::Common -#App::DualLivedList -App::githook_perltidy +App::FargateStack App::Git::Workflow -#App::KGB don't build but doesn't test TP anyway +App::HomeBank2Ledger App::Koyomi -#App::Metabase::Relayd::Plugin::IRC #needs ssl deps (or something) -App::mgen -App::MtAws -App::Munner -#App::Netdisco #needs snmp deps -App::optex -App::OTRS::CreateTicket +App::Metabase::Relayd::Plugin::Dumper +App::Metabase::Relayd::Plugin::IRC +#App::MtAws +#App::Netdisco +#App::PhotoDB App::RunCron -App::scrape +#App::SeismicUnixGui +App::Standup::Diary App::TLSMe -App::tt App::Ylastic::CostAgent -App::YTDL +#App::githook::perltidy +App::gqmt +App::mgen +App::optex +App::tmclean +Archive::SevenZip Armadito::Agent +# Astro::Catalog needs write perms to $Config{installprefix}/etc Astro::Catalog Astro::Coords Astro::FITS::HdrTrans -#Astro::SolarParallax #old -Astro::Sunrise -#AxKit::App::TABOO #old +Astro::SolarParallax +Azure::SAS::Timestamp Beam::Emitter -Bencher::Scenarios::DateModules -#Bio::AutomatedAnnotation #lots of failures +Beam::Make +#Bencher::Scenarios::DateModules +Benchmark::DKbench +#Bio::AutomatedAnnotation Bio::CIPRES -Bootylicious -#Bot::BasicBot::Pluggable::Module::Notes #fails perl 5.26+ -#Bundle::DadaMail -#Bundle::DadaMailXXL -#Bundle::Everything -#Bundle::FinalTest +#Bootylicious +Bot::BasicBot::Pluggable::Module::SimpleBlog Business::FedEx::RateRequest -Business::PL::PESEL +Business::Stripe::Webhook +Business::Tax::Withholding::JP +CA::WAAE +CBOR::XS +CGI::Application::Search +CPAN::Audit +CPAN::Testers::Data::Uploads::Mailer +#CPAN::Testers::WWW::Reports::Mailer +#CPAN::Testers::WWW::Statistics +CPANPLUS +CPANSec::Admin +CSAF Calendar::List +CallBackery Catalyst::Plugin::File::RotateLogs Catalyst::Plugin::Log::Dispatch -Catmandu -CA::WAAE -CGI::Application::Search -CGI::Untaint::datetime -#CGI::Wiki #old -#CGI::Wiki::Kwiki #old -Chef::Knife::Cmd -Class::DBI::Plugin::Calendar -Class::DBI::Plugin::TimePiece +#Class::DBI::FormBuilder +#Class::DBI::Sweet Class::Usul -#Convos #old +CommonsLang Cpanel::JSON::XS -#CPAN::Testers::Data::Generator #needs ssl libs -CPAN::Testers::Data::Uploads::Mailer -#CPAN::Testers::WWW::Admin # needs Image::Magick deps -CPAN::Testers::WWW::Reports::Mailer -CPAN::Testers::WWW::Statistics -Crypt::LE -Cv +#Crypt::LE +#Cv +#DBD::Avatica +DBD::Pg +DBD::PgAsync +DBI::Easy +DBIx::Class::InflateColumn::TimePiece +DBIx::Class +DBIx::Custom +DBIx::Skinny::ProxyTable Dancer::Plugin::Log::DB -#Dancer::SearchApp #needs Elasticsearch +#Dancer::SearchApp Data::Beacon Data::Fake Data::Google::Visualization::DataTable +Data::JPack +Data::Printer Data::Random Data::Sah Data::XLSX::Parser Date::Advent +Date::Age +Date::Cutoff::JP Date::Easy +Date::Format::ISO8601 Date::Holidays::BY +Date::Holidays::EnglandWales Date::Holidays::KZ Date::Holidays::RU -Date::Lectionary Date::Lectionary::Daily Date::Lectionary::Time -#Date::Piece #failing tests +Date::Lectionary Date::Range -#old but leave for now -Date::Simple::Range -DateTime DateTime::Calendar::FrenchRevolutionary -#DateTimeX::Lite #fails on perl 5.26+ -DateTimeX::Moment -Date::Utility -DBI::Easy -DBIx::BulkUtil -DBIx::Class -DBIx::Class::Validation::Structure -DBIx::Custom -DBIx::Schema::Changelog -#DBIx::Skinny::ProxyTable #failing tests -Device::LaCrosse::WS23xx +DateTime +#DateTimeX::Lite +#DateTimeX::Moment +Dist::Maker +Domain::Details +#Dpkg +#EAI::Wrap +#ETL::Yertl +#ETLp Email::Date -Email::Folder -Email::FolderType -Email::Store +#Email::Store Enbld -ETLp -Exception::Chain +Extender +File::Find::IncludesTimeRange +File::KDBX +File::KeePass::KDBX File::Rotate::Simple -File::RsyBak -#File::SAUCE #old +Finance::CoinbasePro::API::CLI +Finance::IG Finance::Quote Finance::Robinhood -Flower +#Flower Fluent::AgentLite -Fluent::Logger +#Fluent::Logger FormValidator::Simple::Struct -#FusionInventory::Agent #failing tests -Gentoo::Util::VirtualDepend +FormValidator::Simple +#Games::Axmud +Genealogy::Gedcom::Date +Giblog Git::TagVersion -GrowthForecast -Haineko -HeliosX::Logger::HiRes -HON::EC2::Snapshots::Monitoring +#GrowthForecast HTTP::Headers::ActionPack +HTTP::State +Haineko ICC::Profile -IkuSan Image::ExifTool -Katsubushi::Client -Labyrinth::Plugin::Survey -#Linux::AtaSmart +Ixchel +JSON::SchemaValidator +JSONSchema::Validator +Kelp +#Labyrinth::Plugin::Survey +#LibUI +Lingua::StarDict::Writer #Linux::GetPidstat Log::Dispatch::Config Log::Dispatch::Pipe @@ -153,166 +164,191 @@ Log::Dump Log::File::Rolling Log::LTSV::Instance Log::Saftpresse +#Mackerel::ReleaseUtils Mail::File #Mail::MtPolicyd -#Makefile::Parallel #needs GraphViz deps +Mail::STS +Makefile::Parallel Marpa::R2 -MarpaX::ESLIF -Mercury -Metabase::Fact Minilla -Minion::Backend::mysql +#Minion::Backend::mysql +Module::Advisor Module::CoreList -Module::CPANTS::Analyse Module::Depends Module::New -Module::Starter::Plugin::CGIApp -Mojolicious -Mojolicious::Plugin::AdditionalValidationChecks -Mojolicious::Plugin::I18NUtils +Module::Release +#Module::Starter::Plugin::CGIApp Mojo::Log::More -Mojo::Webqq Mojo::WebService::Twitter -Mojo::Weixin +#Mojo::Webqq +#Mojo::Weixin Mojo::XMLRPC +Mojolicious::Plugin::AdditionalValidationChecks +Mojolicious::Plugin::FormFieldsFromJSON::Date +Mojolicious +Mojolicious::Plugin::I18NUtils +Mojolicious::Plugin::StaticShare MooseX::Types::Time::Piece +NBI::Slurm +Neo4j::Driver +#Net::API::CPAN +#Net::AWS::SES::Signature4 +#Net::AWS::SES Net::Amazon::DynamoDB::Lite Net::Amazon::S3 +Net::Amazon::SQS::Lite Net::Amazon::Signature::V4 -#Net::AWS::SES +Net::Azure::NotificationHubs +#Net::DSLProvider Net::Domain::ExpireDate -#Net::DSLProvider #failing tests +Net::EPP::Registry::Nominet +Net::SecurityCenter Net::Trustico Nginx::Log::Entry -#OpenGuides +Nginx::Log::Statistics +OPM::Installer OS::Package -OTRS::OPM::Installer -OTRS::OPM::Maker::Command::changes OTRS::SphinxSearch +#OpenGuides +OpenTelemetry::Exporter::OTLP +#OxdPerlModule Oxford::Calendar -PagerDuty::Agent -#Parley #won't build, kinda old -Parse::Crontab -Parse::Syslog::Line PDL::DateTime -#PDL::Graphics::Prima PDL::IO::CSV -Perl::PrereqScanner::NotQuiteLite +PLS +POE::Component::MetaCPAN::Recent +PPI::Prettify +PagerDuty::Agent +Parse::Crontab +Path::Class::Tiny +Pb +Perl::Critic::TooMuchCode Plack::App::Directory::Apaxy +Plack::App::MCCS Plack::Middleware::GitStatus -Plack::Middleware::NeverExpire -Plack::Middleware::TimeOverHTTP -#Plack::Session::Store::RedisFast -#Plucene #old -#Plucene::SearchEngine -#PluceneSimple -#Podcast::ESLPodcast::Splitter +Plack::Middleware::Validate_Google_IAP_JWT Pod::Cpandoc::Cache -POE::Component::FeedAggregator -#POSIX::1003 test failures -PowerTools::Data -PPI::Prettify -Process::Child::Leash +#Podcast::ESLPodcast::Splitter +Poz #Project::Easy +#Project2::Gantt Riji Rubric +#SBOM::CycloneDX +SMS::Send::SMSGlobal::HTTP +#SPVM::R +SPVM::Time::Piece +SPVM +STIX +SVG::Timeline::Genealogy +Sagan::Monitoring Salvation::TC Script::Ichigeki -#Script::Nohup +Script::Nohup #Seis #Sendmail::Queue Sentry::Raven Setup::Project -Shell::Carapace +#Sidef +Signer::AWSv4 Sisimai Slovo Smart::Options -SMS::Send::SMSGlobal::HTTP -SMS::SMS77 -#SPOPS #old -Spp +SmsAero +#Spp Spreadsheet::HTML -stasis -Statocles +String::CodiceFiscale String::Errf +#String::Incremental String::Template -SVG::Timeline +Suricata::Monitoring Sweet::Home -Tangram -#Task::Amon2 -#Task::DualLived -#Task::Litmus -Teamcity::Executor -Term::Choose -Term::Choose_HAE +#Tangram +Term::Choose::LineFold::XS Term::Choose::Util +Term::Choose Term::Form Term::TablePrint -Test2::Tools::SkipUntil -Test::APIcast Test::BDD::Cucumber::Harness::Html Test::BDD::Cucumber::Harness::Nagios -#Test::BDD::Infrastructure #needs Augeas deps Test::Deep::Between Test::JsonAPI::Autodoc Test::MockTime Test::Sims Test::Time::At -#Text::Clevery #old +Test2::Tools::SkipUntil +Text::Xslate Time::Ago +#Time::C Time::Crontab -Time::dt -Time::Duration::Concise::Localize +Time::FFI Time::Format::MySQL Time::List -Time::Moment::Epoch +Time::Moment Time::Moment::Ext +Time::Moment::Role::Strptime Time::ParseDate -#old but leave for now -Time::Piece::Adaptive Time::Piece::DayOfWeek +Time::Piece::Guess Time::Piece::ISO Time::Piece::MSSQL -#old but leave for now -Time::Piece::MySQL Time::Piece::Over24 Time::Piece::Plus -Time::Piece::Range Time::Random -#old but leave for now -Time::Simple::Range Time::Stamp Time::Strptime -Time::Vector +#Time::dt +Tiny::Prof +Twitter::ID Types::SQL -Ukigumo::Client +#Ukigumo::Agent +#VCS::Lite::Repository Validator::Custom -VCS::Lite::Repository -Web::Machine -Webqq::Client -WebService::Amazon::Signature -WebService::ForecastIO -WebService::Mailgun -Webservice::Shipment -Weixin::Client -Wiki::Toolkit -Wiki::Toolkit::Plugin::JSON -Wiki::Toolkit::Store::Mediawiki -WorkerManager -WorldCat::API -WWW::betfair +Validator::LIVR +Venus +Virani +#Vote::Count WWW::CheckHTML WWW::GoKGS WWW::KGS::GameArchives WWW::Mixi::Scraper #WWW::NHKProgram::API +WWW::Noss WWW::Ohloh::API -WWW::Oxontime -#WWW::Plurk #old +#WWW::Oxontime WWW::Scraper::F1 -WWW::Topica -#WWW::UsePerl::Journal #WWW::UsePerl::Journal::Thread -#Wx just no +#WWW::UsePerl::Journal +WWW::betfair +#Weather::GHCN::Fetch +Weather::MOSMIX +Web::Machine +WebService::Amazon::Signature +WebService::ForecastIO +WebService::OurWorldInData +Webqq::Client +Webservice::Shipment +Weixin::Client +Whelk +Wiki::Toolkit::Plugin::JSON +Wiki::Toolkit +#Win32::ADRecurse +#Win32::Backup::Robocopy +#Win32::Console::DotNet +#Win32::Console::PatchForRT33513 +WorkerManager +WorldCat::API XML::Atom::SimpleFeed -XML::Compile +YATT::Lite +Yancy +#Zapp +#Zing +#Date::Piece +Date::Simple::Month +Date::Simple::Range +Time::PT +Time::Piece::Adaptive +Time::Piece::Month +Time::Piece::MySQL +Time::Piece::Range +Time::Simple::Range diff --git a/t/09locales.t b/t/09locales.t index 8770519..7bafa35 100644 --- a/t/09locales.t +++ b/t/09locales.t @@ -48,6 +48,11 @@ sub check_parsed ); cmp_ok( $parsed->strftime(), 'eq', $t->strftime(), 'Outputs formatted as default match' ); + cmp_ok( + $parsed->datetime(), 'eq', + $t->strftime("%Y-%m-%dT%H:%M:%S"), + 'datetime() matches strftime()' + ); } my @dates = ( @@ -68,6 +73,7 @@ for my $time ( time(), # Now, whenever that might be 1451606400, # 2016-01-01 00:00 1451653500, # 2016-01-01 13:05 + 1449014400, # 2015-12-02 00:00 ) { my $t = gmtime($time); @@ -94,6 +100,7 @@ for my $time ( time(), # Now, whenever that might be 1451606400, # 2016-01-01 00:00 1451653500, # 2016-01-01 13:05 + 1449014400, # 2015-12-02 00:00 ) { my $t = localtime($time); @@ -115,4 +122,4 @@ for my $time ( } -done_testing(136); +done_testing(234); diff --git a/t/11strptime_defaults.t b/t/11strptime_defaults.t index 13b6362..d7086db 100644 --- a/t/11strptime_defaults.t +++ b/t/11strptime_defaults.t @@ -1,7 +1,7 @@ #!/usr/bin/perl -w use Test::More tests => 129; -my $is_linux = ($^O =~ /linux/); +my $is_linux = ( $^O =~ /linux/ ); BEGIN { use_ok('Time::Piece'); } @@ -427,7 +427,7 @@ my @known_localtime = localtime(1753440879); is( $tp3->mday, 15, "No format, object defaults: Day parsed correctly" ); is( $tp3->mon, 3, "No format, object defaults: Month parsed correctly" ); is( $tp3->year, 2025, "No format, object defaults: Parsed year overrides" ); - is( $tp3->hour, 14, "No format, object defaults: Hour parsed correctly" ); + is( $tp3->min, 30, "No format, object defaults: Minute parsed correctly" ); is( $tp3->[Time::Piece::c_islocal], 1, "No format, object copied local" ); } diff --git a/t/12strptime_timezones.t b/t/12strptime_timezones.t new file mode 100644 index 0000000..a009f1e --- /dev/null +++ b/t/12strptime_timezones.t @@ -0,0 +1,453 @@ +use Test::More; + +plan skip_all => "Timezone parsing not required for installation" + unless ( $ENV{PERL_BATCH} + || $ENV{AUTOMATED_TESTING} + || $ENV{NONINTERACTIVE_TESTING} ); + +use Time::Piece; + +sub test_parse +{ + my ( $str, $fmt, $tests, $desc_prefix, $opts ) = @_; + $opts ||= {}; + + my $tp = eval { + if ( defined $opts->{islocal} ) { + Time::Piece->strptime( $str, $fmt, + { islocal => $opts->{islocal} } ); + } else { + Time::Piece->strptime( $str, $fmt ); + } + }; + + ok( !$@, "$desc_prefix: Parsed successfully" ) or diag("Error: $@"); + return unless $tp; + + for my $field ( keys %$tests ) { + + if ( $field eq 'hour_range' ) { + my ( $min, $max ) = @{ $tests->{$field} }; + ok( + $tp->hour >= $min && $tp->hour <= $max, + "$desc_prefix: hour in range [$min, $max] (got " + . $tp->hour . ")" + ); + } elsif ( $field eq 'islocal' ) { + is( $tp->[Time::Piece::c_islocal], + $tests->{$field}, "$desc_prefix: islocal = $tests->{$field}" ); + } else { + is( $tp->$field, $tests->{$field}, + "$desc_prefix: $field = $tests->{$field}" ); + } + } + + return $tp; +} + +sub with_tz +{ + my ( $tz, $code ) = @_; + my $orig_tz = $ENV{TZ}; + local $ENV{TZ} = $tz; + Time::Piece::_tzset(); + $code->(); + if ( defined $orig_tz ) { + $ENV{TZ} = $orig_tz; + } else { + delete $ENV{TZ}; + } + Time::Piece::_tzset(); +} + +# ============================================================================ +# %z NUMERIC TIMEZONE OFFSET TESTS +# ============================================================================ + +# Basic %z offset tests - how time is adjusted to UTC +my @z_offset_tests = ( + + # [offset, input_hour, expected_hour, expected_min, desc] + [ "+0000", 10, 10, 30, "UTC/Zero offset" ], + [ "+0100", 10, 9, 30, "UTC+1" ], + [ "+0500", 10, 5, 30, "UTC+5" ], + [ "+0530", 10, 5, 0, "UTC+5:30" ], + [ "-0100", 10, 11, 30, "UTC-1" ], + [ "-0500", 10, 15, 30, "UTC-5" ], + [ "-0730", 10, 18, 0, "UTC-7:30" ], +); + +for my $test (@z_offset_tests) { + my ( $offset, $hour_in, $hour_out, $min_out, $desc ) = @$test; + test_parse( + "2025-01-15 $hour_in:30:45 $offset", + "%Y-%m-%d %H:%M:%S %z", + { + year => 2025, + mon => 1, + mday => 15, + hour => $hour_out, + min => $min_out, + sec => 45 + }, + "%z $desc" + ); +} + +# Day wrapping tests with %z +my @z_day_wrap_tests = ( + + # [offset, time, expected_day, expected_hour, desc] + # Positive offsets that wrap to previous day + [ "+0800", "02:00:00", 14, 18, "wrap to previous day" ], + [ "+1200", "01:30:00", 14, 13, "wrap to previous day with half hour" ], + [ "+1400", "00:30:00", 14, 10, "maximum positive offset wrap" ], + + # Negative offsets that wrap to next day + [ "-0400", "22:00:00", 16, 2, "wrap to next day" ], + [ "-0800", "20:45:00", 16, 4, "wrap to next day with minutes" ], + [ "-1200", "23:30:00", 16, 11, "maximum negative offset wrap" ], +); + +for my $test (@z_day_wrap_tests) { + my ( $offset, $time, $exp_day, $exp_hour, $desc ) = @$test; + test_parse( + "2025-01-15 $time $offset", + "%Y-%m-%d %H:%M:%S %z", + { year => 2025, mon => 1, mday => $exp_day, hour => $exp_hour }, + "%z day wrap: $offset $desc" + ); +} + +# Month/Year boundary wrapping with %z +my @z_boundary_tests = ( + + # [date_time, offset, exp_year, exp_mon, exp_day, exp_hour, desc] + [ "2025-01-01 01:00:00", "+0300", 2024, 12, 31, 22, "Year boundary back" ], + [ "2024-12-31 23:30:00", "-0200", 2025, 1, 1, 1, "Year boundary forward" ], + [ "2025-01-31 23:00:00", "-0200", 2025, 2, 1, 1, "Month boundary forward" ], + [ "2024-02-29 01:00:00", "+0500", 2024, 2, 28, 20, "Leap day backward" ], + [ "2024-02-28 23:00:00", "-0300", 2024, 2, 29, 2, "Leap day forward" ], +); + +for my $test (@z_boundary_tests) { + my ( $datetime, $offset, $exp_year, $exp_mon, $exp_day, $exp_hour, $desc ) + = @$test; + test_parse( + "$datetime $offset", + "%Y-%m-%d %H:%M:%S %z", + { + year => $exp_year, + mon => $exp_mon, + mday => $exp_day, + hour => $exp_hour + }, + "%z boundary: $desc" + ); +} + +# ============================================================================ +# %Z TIMEZONE NAME TESTS +# ============================================================================ + +# Basic %Z timezone name recognition +my @Z_basic_tests = ( + [ "GMT", 10, 0, "GMT recognized as UTC" ], + [ "UTC", 10, 0, "UTC recognized" ], + [ "PST", 10, 0, "PST treated as GMT (islocal=0)" ], + [ "EST", 10, 0, "EST treated as GMT (islocal=0)" ], +); + +for my $test (@Z_basic_tests) { + my ( $tz, $exp_hour, $exp_islocal, $desc ) = @$test; + test_parse( + "2025-01-15 10:30:45 $tz", + "%Y-%m-%d %H:%M:%S %Z", + { year => 2025, hour => $exp_hour, islocal => $exp_islocal }, + "%Z $desc" + ); +} + +# %Z with GMT to local conversion +with_tz( + "EST5EDT4,M3.2.0/2,M11.1.0/2", + sub { + test_parse( + "2025-01-15 15:30:45 GMT", + "%Y-%m-%d %H:%M:%S %Z", + { hour => 15, islocal => 0 }, + "%Z GMT->GMT: default" + ); + + test_parse( + "2025-01-15 15:30:45 GMT", + "%Y-%m-%d %H:%M:%S %Z", + { hour => 10, islocal => 1 }, + "%Z GMT->Local: forced local", + { islocal => 1 } + ); + + test_parse( + "2025-01-15 15:30:45 +0300", + "%Y-%m-%d %H:%M:%S %z", + { hour => 7, islocal => 1 }, + "%z +0300->Local", + { islocal => 1 } + ); + + test_parse( + "2025-01-15 01:00:00 GMT", + "%Y-%m-%d %H:%M:%S %Z", + { mday => 14, hour => 20, islocal => 1 }, + "%Z GMT day boundary", + { islocal => 1 } + ); + } +); + +# ============================================================================ +# DST TESTS (Daylight Saving Time) +# ============================================================================ + +# Mar 10, 2024 - Daylight Saving Time Started +# DST Spring Forward (2:00 AM -> 3:00 AM) +with_tz( + "EST5EDT4,M3.2.0/2,M11.1.0/2", + sub { + # Valid time before transition + test_parse( + "2024-03-10 01:30:00", + "%Y-%m-%d %H:%M:%S", + { year => 2024, mon => 3, mday => 10, hour => 1, min => 30 }, + "DST spring: before transition", + { islocal => 1 } + ); + + # Non-existent time during spring forward + my $tp = test_parse( + "2024-03-10 02:30:00", + "%Y-%m-%d %H:%M:%S", + { year => 2024, mon => 3, mday => 10, hour => 2 }, + "DST spring: non-existent time", + { islocal => 1 } + ); + + # Valid time after transition + test_parse( + "2024-03-10 03:30:00", + "%Y-%m-%d %H:%M:%S", + { year => 2024, hour => 3, min => 30 }, + "DST spring: after transition", + { islocal => 1 } + ); + + # GMT during DST should be unaffected + test_parse( + "2024-03-10 07:30:00 GMT", + "%Y-%m-%d %H:%M:%S %Z", + { year => 2024, hour => 7, islocal => 0 }, + "DST spring: GMT unaffected" + ); + + # GMT to local during DST + test_parse( + "2024-03-10 07:30:00 GMT", + "%Y-%m-%d %H:%M:%S %Z", + { year => 2024, hour_range => [ 2, 3 ], islocal => 1 }, + "DST spring: GMT->local conversion", + { islocal => 1 } + ); + + # %z tests during spring forward transition + # Time with %z offset that results in non-existent local time + test_parse( + "2024-03-10 07:30:00 +0000", # 7:30 UTC = 2:30 EST (non-existent) + "%Y-%m-%d %H:%M:%S %z", + { year => 2024, hour => 7, min => 30, islocal => 0 }, + "DST spring %z: UTC time during transition (GMT object)" + ); + + # Same but forced to local - should handle non-existent 2:30 AM + test_parse( + "2024-03-10 07:30:00 +0000", # 7:30 UTC = 2:30 EST (non-existent) + "%Y-%m-%d %H:%M:%S %z", + { year => 2024, hour_range => [ 2, 3 ], islocal => 1 }, + "DST spring %z: UTC->local during non-existent time", + { islocal => 1 } + ); + + # %z offset that results in time before DST transition + test_parse( + "2024-03-10 06:00:00 +0000", # 1:00 EST (before transition) + "%Y-%m-%d %H:%M:%S %z", + { year => 2024, hour => 1, islocal => 1 }, + "DST spring %z: UTC->local before transition", + { islocal => 1 } + ); + + # %z offset that results in time after DST transition + test_parse( + "2024-03-10 08:00:00 +0000", #4:00 EDT (after transition) + "%Y-%m-%d %H:%M:%S %z", + { year => 2024, hour_range => [ 3, 4 ], islocal => 1 }, + "DST spring %z: UTC->local after transition", + { islocal => 1 } + ); + + # Non-UTC offset during transition + test_parse( + "2024-03-10 10:30:00 +0300", # 7:30 UTC = 2:30/3:30 local + "%Y-%m-%d %H:%M:%S %z", + { year => 2024, hour_range => [ 2, 3 ], islocal => 1 }, + "DST spring %z: +0300->local during transition", + { islocal => 1 } + ); + } +); + +# Nov 3, 2024 - Daylight Saving Time Ended +# DST Fall Back (2:00 AM -> 1:00 AM) +with_tz( + "EST5EDT4,M3.2.0/2,M11.1.0/2", + sub { + # Before ambiguous time + test_parse( + "2024-11-03 00:30:00", + "%Y-%m-%d %H:%M:%S", + { year => 2024, hour => 0, min => 30 }, + "DST fall: before ambiguous", + { islocal => 1 } + ); + + # Ambiguous time (occurs twice) + test_parse( + "2024-11-03 01:30:00", + "%Y-%m-%d %H:%M:%S", + { year => 2024, hour => 1, min => 30 }, + "DST fall: ambiguous time", + { islocal => 1 } + ); + + # After both occurrences + test_parse( + "2024-11-03 02:30:00", + "%Y-%m-%d %H:%M:%S", + { year => 2024, hour => 2, min => 30 }, + "DST fall: after ambiguous", + { islocal => 1 } + ); + + # Explicit timezone during fall-back + test_parse( + "2024-11-03 01:30:00 EST", + "%Y-%m-%d %H:%M:%S %Z", + { year => 2024, hour => 1 }, + "DST fall: explicit EST", + { islocal => 1 } + ); + + # %z tests during fall back transition + # Time with %z that results in first occurrence of 1:30 AM EDT + test_parse( + "2024-11-03 05:30:00 +0000", # = 1:30 AM EDT (first occurrence) + "%Y-%m-%d %H:%M:%S %z", + { year => 2024, hour => 5, min => 30, islocal => 0 }, + "DST fall %z: UTC during ambiguous (GMT object)" + ); + + # Same but forced to local - ambiguous 1:30 AM + test_parse( + "2024-11-03 05:30:00 +0000", # 5:30 UTC = 1:30 AM EDT (first) + "%Y-%m-%d %H:%M:%S %z", + { year => 2024, hour_range => [ 0, 1 ], min => 30, islocal => 1 }, + "DST fall %z: UTC->local first 1:30 AM", + { islocal => 1 } + ); + + # Time with %z that results in second occurrence of 1:30 AM EST + test_parse( + "2024-11-03 06:30:00 +0000", # 6:30 UTC = 1:30 AM EST (second) + "%Y-%m-%d %H:%M:%S %z", + { year => 2024, hour => 1, min => 30, islocal => 1 }, + "DST fall %z: UTC->local second 1:30 AM", + { islocal => 1 } + ); + + # %z offset after ambiguous period + test_parse( + "2024-11-03 07:00:00 +0000", # 7:00 UTC = 2:00 AM EST + "%Y-%m-%d %H:%M:%S %z", + { year => 2024, hour => 2, islocal => 1 }, + "DST fall %z: UTC->local after ambiguous", + { islocal => 1 } + ); + + # Non-UTC offset during ambiguous time + test_parse( + "2024-11-03 09:30:00 +0400", # 5:30 UTC = 1:30 AM EDT (first) + "%Y-%m-%d %H:%M:%S %z", + { year => 2024, hour_range => [ 0, 1 ], min => 30, islocal => 1 }, + "DST fall %z: +0400->local during ambiguous", + { islocal => 1 } + ); + + # Negative offset during fall back + test_parse( + "2024-11-03 00:30:00 -0500", # 0:30-05:00 = 5:30 UTC = 1:30 AM EDT + "%Y-%m-%d %H:%M:%S %z", + { year => 2024, hour_range => [ 0, 1 ], min => 30, islocal => 1 }, + "DST fall %z: -0500->local during ambiguous", + { islocal => 1 } + ); + } +); + +# Different timezone DST rules +with_tz( + "PST8PDT,M3.2.0/2,M11.1.0/2", + sub { + # Pacific timezone spring forward + test_parse( + "2024-03-10 02:30:00", + "%Y-%m-%d %H:%M:%S", + { year => 2024, hour => 2 }, + "DST Pacific: spring forward", + { islocal => 1 } + ); + + # GMT to Pacific during summer (PDT) + test_parse( + "2024-07-15 20:00:00 GMT", + "%Y-%m-%d %H:%M:%S %Z", + { year => 2024, hour_range => [ 12, 13 ], islocal => 1 }, + "DST Pacific: GMT->PDT summer", + { islocal => 1 } + ); + } +); + +# European DST (different dates than US) +with_tz( + "CET-1CEST,M3.5.0,M10.5.0/3", + sub { + # European spring forward (last Sunday in March) + test_parse( + "2024-03-31 02:30:00", + "%Y-%m-%d %H:%M:%S", + { year => 2024, hour => 2 }, + "DST Europe: spring forward", + { islocal => 1 } + ); + + # European fall back (last Sunday in October) + test_parse( + "2024-10-27 02:30:00", + "%Y-%m-%d %H:%M:%S", + { year => 2024, hour => 2 }, + "DST Europe: fall back", + { islocal => 1 } + ); + } +); + +done_testing(234); diff --git a/t/13date_arithmetic_edge_cases.t b/t/13date_arithmetic_edge_cases.t new file mode 100644 index 0000000..a9bca01 --- /dev/null +++ b/t/13date_arithmetic_edge_cases.t @@ -0,0 +1,469 @@ +use strict; +use warnings; +use Test::More; +use Time::Piece; +use Time::Seconds; +use Time::Local; + +# This test file documents edge cases and oddities in Time::Piece date arithmetic. +# Many of these behaviors may seem counterintuitive but are "working as designed." +# The goal is to clearly document what Time::Piece does, not necessarily what +# users might expect it to do. + +my $is_linux = ( $^O =~ /linux/ ); +my $is_bsd = ( $^O =~ /bsd/ ); +my $is_mac = ( $^O =~ /darwin/ ); + +# Skip DST tests unless in automated testing environment +plan skip_all => "Arithmetic edge case tests not required for installation" + unless ( $ENV{AUTOMATED_TESTING} + || $ENV{NONINTERACTIVE_TESTING} + || $ENV{PERL_BATCH} ); + +# ============================================================================= +# Section 1: Simple Arithmetic with Seconds (Epoch-based) +# ============================================================================= +note("=== Section 1: Simple Arithmetic (Epoch-based) ==="); + +{ + # TEST 1.1: Basic epoch arithmetic with gmtime + # All +/- operations with seconds use epoch arithmetic + my $t = gmtime(1000000000); # 2001-09-09 01:46:40 GMT + my $t_plus = $t + 3600; # Add 1 hour + my $t_minus = $t - 3600; # Subtract 1 hour + + is( $t_plus->hour, 2, 'gmtime + 3600 seconds increases hour by 1' ); + is( $t_minus->hour, 0, 'gmtime - 3600 seconds decreases hour by 1' ); +} + +{ + # TEST 1.2: Subtraction between two Time::Piece objects + # Returns Time::Seconds object containing epoch difference + my $t1 = gmtime(1000003600); + my $t2 = gmtime(1000000000); + my $diff = $t1 - $t2; + + isa_ok( $diff, 'Time::Seconds', 'Subtracting two Time::Piece objects' ); + is( $diff->seconds, 3600, 'Difference is 3600 seconds (epoch difference)' ); +} + +{ + # TEST 1.3: gmtime objects are always UTC (islocal=0) + # No DST complications for gmtime + my $t = gmtime(1362909000); + my $t_plus = $t + 7200; # Add 2 hours + + is( + $t_plus->hour, + ( $t->hour + 2 ) % 24, + 'gmtime arithmetic is simple UTC arithmetic' + ); + ok( !$t_plus->[10], 'gmtime result maintains islocal=0' ); +} + +# ============================================================================= +# Section 2: DST Boundary Tests (localtime objects) +# ============================================================================= +SKIP: { + skip "DST tests for Linux/BSD/Mac only", 12 + unless ( $is_linux || $is_bsd || $is_mac ); + + note("=== Section 2: DST Boundary Tests ==="); + + local $ENV{TZ} = "EST5EDT4,M3.2.0/2,M11.1.0/2"; + Time::Piece::_tzset(); + + { +# TEST 2.1: Spring Forward DST transition (adding seconds) +# On 2013-03-10, clocks jump from 2:00 AM EST to 3:00 AM EDT +# Adding 1 hour to 1:30 AM should result in 3:30 AM (not 2:30, which doesn't exist) + my $before = localtime(1362897000); # 2013-03-10 01:30:00 EST + is( + $before->strftime("%Y-%m-%d %H:%M:%S %Z"), + '2013-03-10 01:30:00 EST', + 'Before spring-forward: 1:30 AM EST' + ); + + my $after = $before + 3600; # Add 1 hour (3600 seconds) + is( + $after->strftime("%Y-%m-%d %H:%M:%S %Z"), + '2013-03-10 03:30:00 EDT', + 'After adding 3600s: 3:30 AM EDT (skipped over 2:30 AM)' + ); + is( $after->hour, 3, "hour is 3" ); + } + + { + # TEST 2.2: Fall Back DST transition (adding seconds) + # On 2013-11-03, clocks fall back from 2:00 AM EDT to 1:00 AM EST + # The hour from 1:00-2:00 occurs twice + my $before = + localtime(1383454800); # 2013-11-03 01:00:00 EDT (first occurrence) + is( + $before->strftime("%Y-%m-%d %H:%M:%S"), + '2013-11-03 01:00:00', + 'Before fall-back: 1:00 AM EDT' + ); + + my $after = $before + 3600; # Add 1 hour + # This crosses into the second occurrence of 1:00 AM (now EST) + is( + $after->strftime("%Y-%m-%d %H:%M:%S"), + '2013-11-03 01:00:00', + 'After adding 3600s: 1:00 AM EST (second occurrence of 1:00 AM)' + ); + is( $after->strftime("%Z"), 'EST', 'Timezone is now EST' ); + is( $after->hour, 1, "hour is 1" ); + } + + { +# TEST 2.3: ODDITY - Subtraction across DST gives epoch difference, not wall-clock difference +# This demonstrates that subtraction uses epoch, not wall-clock time + my $before_spring = localtime(1362897000); # 2013-03-10 01:30:00 EST + my $after_spring = localtime(1362900600); # 2013-03-10 03:30:00 EDT + + my $diff = $after_spring - $before_spring; + +# Wall-clock shows "2 hours" (1:30 AM to 3:30 AM) +# But epoch difference is only 3600 seconds (1 hour) because 2:00-3:00 didn't exist + is( $diff->seconds, 3600, +'ODDITY: Across spring DST, wall-clock shows 2h but epoch diff is 1h (3600s)' + ); + is( $diff->hours, 1, + 'Time::Seconds reports 1 hour, not 2 (epoch-based)' ); + } + + { + # TEST 2.4: Fall-back subtraction oddity + # The reverse oddity: wall-clock shows 1 hour, epoch shows more + my $before_fall = localtime(1383454800); # 2013-11-03 01:00:00 EDT + my $after_fall = localtime(1383462000); # 2013-11-03 02:00:00 EST + + my $diff = $after_fall - $before_fall; + + # Wall-clock shows "1 hour" (1:00 AM to 2:00 AM) + # But epoch difference is 7200 seconds because 1:00-2:00 occurred twice + is( $diff->seconds, 7200, +'ODDITY: Across fall DST, wall-clock shows 1h but epoch diff is 2h (7200s)' + ); + } + + { + # TEST 2.5: localtime + seconds maintains islocal=1 + my $t = localtime(1362909000); + my $t_plus = $t + 3600; + + ok( $t_plus->[10], 'localtime + seconds maintains islocal=1' ); + } +} + +# ============================================================================= +# Section 3: add_months() Edge Cases +# ============================================================================= +note("=== Section 3: add_months() Edge Cases ==="); + +{ + # TEST 3.1: ODDITY - Month-end overflow (31-day → 28-day month) + # Jan 31 + 1 month does NOT clamp to Feb 28 + # Instead: Feb 31 is "3 days past Feb 28" → normalizes to March 3 + my $jan31 = + gmtime( timegm( 0, 0, 12, 31, 0, 113 ) ); # 2013-01-31 12:00:00 UTC + my $result = $jan31->add_months(1); + + is( $result->ymd, '2013-03-03', + 'ODDITY: Jan 31 + 1 month = March 3 (not Feb 28) - overflow normalizes' + ); + is( $result->hour, 12, 'Time of day preserved' ); + ok( !$result->[10], 'gmtime maintains islocal=0 after add_months' ); +} + +{ + # TEST 3.2: Month-end overflow in leap year + # Jan 31 + 1 month in leap year + # Feb 31 is "2 days past Feb 29" → March 2 + my $jan31 = + gmtime( timegm( 0, 0, 12, 31, 0, 112 ) ); # 2012-01-31 12:00:00 UTC + my $result = $jan31->add_months(1); + + is( $result->ymd, '2012-03-02', + 'Leap year: Jan 31 + 1 month = March 2 (2 days past Feb 29)' ); +} + +{ + # TEST 3.3: Month-end overflow (31-day → 30-day month) + # Aug 31 + 1 month → Sep 31 → Oct 1 + my $aug31 = + gmtime( timegm( 0, 0, 12, 31, 7, 113 ) ); # 2013-08-31 12:00:00 UTC + my $result = $aug31->add_months(1); + + is( $result->ymd, '2013-10-01', + 'Aug 31 + 1 month = Oct 1 (1 day past Sep 30)' ); +} + +{ + # TEST 3.4: Multiple months showing direct calculation + # Jan 31 + 2 months → March 31 (no February normalization in between) + my $jan31 = + gmtime( timegm( 0, 0, 12, 31, 0, 113 ) ); # 2013-01-31 12:00:00 UTC + my $result = $jan31->add_months(2); + + is( $result->ymd, '2013-03-31', + 'Jan 31 + 2 months = March 31 (direct calculation, no intermediate Feb)' + ); +} + +{ + # TEST 3.5: Negative month addition with overflow + # March 31 - 1 month → Feb 31 → March 3 + my $mar31 = + gmtime( timegm( 0, 0, 12, 31, 2, 113 ) ); # 2013-03-31 12:00:00 UTC + my $result = $mar31->add_months(-1); + + is( $result->ymd, '2013-03-03', + 'March 31 - 1 month = March 3 (negative months also normalize)' ); +} + +{ + # TEST 3.6: Year boundary crossing forward + # Dec 15 + 2 months → Feb 15 (next year) + my $dec15 = + gmtime( timegm( 0, 0, 12, 15, 11, 113 ) ); # 2013-12-15 12:00:00 UTC + my $result = $dec15->add_months(2); + + is( $result->ymd, '2014-02-15', + 'Dec 15 + 2 months crosses year boundary to Feb 15 next year' ); + is( $result->year, 2014, 'Year correctly increments' ); +} + +{ + # TEST 3.7: Year boundary crossing backward + # Jan 15 - 3 months → Oct 15 (previous year) + my $jan15 = + gmtime( timegm( 0, 0, 12, 15, 0, 113 ) ); # 2013-01-15 12:00:00 UTC + my $result = $jan15->add_months(-3); + + is( $result->ymd, '2012-10-15', + 'Jan 15 - 3 months crosses year boundary to Oct 15 previous year' ); + is( $result->year, 2012, 'Year correctly decrements' ); +} + +SKIP: { + skip "DST tests for Linux/BSD/Mac only", 6 + unless ( $is_linux || $is_bsd || $is_mac ); + + note("=== Section 3b: add_months() DST Behavior ==="); + + local $ENV{TZ} = "EST5EDT4,M3.2.0/2,M11.1.0/2"; + Time::Piece::_tzset(); + + { + # TEST 3.8: ODDITY - add_months preserves wall-clock time, not UTC offset + # Jan 10 07:00 EST (UTC-5) + 3 months → Apr 10 07:00 EDT (UTC-4) + # The wall-clock hour stays 07:00, but UTC relationship changes + my $jan = localtime( timegm( 0, 0, 12, 10, 0, 113 ) ) + ; # 2013-01-10 12:00:00 UTC = 07:00 EST + is( $jan->strftime("%H:%M %Z"), '07:00 EST', 'Start: 07:00 EST' ); + + my $apr = $jan->add_months(3); + is( + $apr->strftime("%Y-%m-%d %H:%M"), + '2013-04-10 07:00', + 'ODDITY: add_months preserves wall-clock time (07:00)' + ); + is( $apr->strftime("%Z"), + 'EDT', + 'But timezone changed to EDT (UTC offset changed from -5 to -4)' ); + } + + { + # TEST 3.9: Fall-back DST transition + # Jul 10 14:00 EDT (UTC-4) + 5 months → Dec 10 14:00 EST (UTC-5) + my $jul = localtime( timegm( 0, 0, 18, 10, 6, 113 ) ) + ; # 2013-07-10 18:00 UTC = 14:00 EDT + is( $jul->strftime("%H:%M %Z"), '14:00 EDT', 'Start: 14:00 EDT' ); + + my $dec = $jul->add_months(5); + is( + $dec->strftime("%Y-%m-%d %H:%M"), + '2013-12-10 14:00', + 'Wall-clock time preserved (14:00)' + ); + is( $dec->strftime("%Z"), + 'EST', + 'Timezone changed to EST (UTC offset changed from -4 to -5)' ); + } +} + +# ============================================================================= +# Section 4: add_years() Edge Cases +# ============================================================================= +note("=== Section 4: add_years() Edge Cases ==="); + +{ + # TEST 4.1: ODDITY - Leap year → non-leap year + # Feb 29, 2012 + 1 year → Feb 29, 2013 → March 1, 2013 (normalizes) + my $feb29 = + gmtime( timegm( 0, 0, 12, 29, 1, 112 ) ); # 2012-02-29 12:00:00 UTC + my $result = $feb29->add_years(1); + + is( $result->ymd, '2013-03-01', +'ODDITY: Feb 29 leap year + 1 year = March 1 (Feb 29 normalized in non-leap year)' + ); +} + +{ + # TEST 4.2: Leap year → leap year + # Feb 29, 2012 + 4 years → Feb 29, 2016 (stays on leap day) + my $feb29 = + gmtime( timegm( 0, 0, 12, 29, 1, 112 ) ); # 2012-02-29 12:00:00 UTC + my $result = $feb29->add_years(4); + + is( $result->ymd, '2016-02-29', + 'Feb 29 + 4 years = Feb 29 (leap year to leap year)' ); +} + +{ + # TEST 4.3: Century leap year edge (2000 → 2100) + # 2100 is NOT a leap year (not divisible by 400) + # Feb 29, 2000 + 100 years → Feb 29, 2100 → March 1, 2100 + my $feb29_2000 = + gmtime( timegm( 0, 0, 12, 29, 1, 100 ) ); # 2000-02-29 12:00:00 UTC + my $result = $feb29_2000->add_years(100); + + is( $result->ymd, '2100-03-01', + 'Feb 29, 2000 + 100 years = March 1, 2100 (2100 not leap year)' ); +} + +{ + # TEST 4.4: Negative years + # 2015-02-28 - 1 year → 2014-02-28 + my $feb28 = + gmtime( timegm( 0, 0, 12, 28, 1, 115 ) ); # 2015-02-28 12:00:00 UTC + my $result = $feb28->add_years(-1); + + is( $result->ymd, '2014-02-28', 'Feb 28 - 1 year = Feb 28 previous year' ); +} + +{ + # TEST 4.5: gmtime maintains islocal=0 + my $t = gmtime( timegm( 0, 0, 12, 15, 5, 113 ) ); # 2013-06-15 12:00:00 UTC + my $result = $t->add_years(2); + + is( $result->ymd, '2015-06-15', 'add_years works for gmtime' ); + ok( !$result->[10], 'gmtime maintains islocal=0 after add_years' ); +} + +SKIP: { + skip "DST tests for Linux/BSD/Mac only", 3 + unless ( $is_linux || $is_bsd || $is_mac ); + + note("=== Section 4b: add_years() DST Behavior ==="); + + local $ENV{TZ} = "EST5EDT4,M3.2.0/2,M11.1.0/2"; + Time::Piece::_tzset(); + + { +# TEST 4.6: add_years preserves wall-clock time across different DST transition dates +# In 2013, spring-forward is March 10; in 2014, it's March 9 +# But wall-clock time is still preserved + my $march = + localtime( timegm( 0, 30, 6, 10, 2, 113 ) ); # 2013-03-10 01:30:00 EST + is( + $march->strftime("%Y-%m-%d %H:%M %Z"), + '2013-03-10 01:30 EST', + 'Start: 2013-03-10 01:30 EST (day of spring-forward)' + ); + + my $next_year = $march->add_years(1); + is( + $next_year->strftime("%Y-%m-%d %H:%M"), + '2014-03-10 01:30', + 'add_years preserves wall-clock time (01:30)' + ); + + # In 2014, March 10 is AFTER spring-forward (which was March 9) + is( $next_year->strftime("%Z"), + 'EDT', + '2014-03-10 is in EDT (spring-forward was March 9 in 2014)' ); + } +} + +# ============================================================================= +# Section 5: Documented Oddities and Mixed Operations +# ============================================================================= +note("=== Section 5: Documented Oddities ==="); + +{ + # TEST 5.1: ODDITY - Date normalization does not clamp + # This is the fundamental oddity: invalid dates normalize forward + my $jan31 = gmtime( timegm( 0, 0, 12, 31, 0, 113 ) ); # 2013-01-31 + + # Feb has 28 days, so Feb 31 is 3 days past the end + my $feb = $jan31->add_months(1); + is( $feb->mday, 3, + 'ODDITY: Overflow days are added to next month (not clamped)' ); + is( $feb->mon, 3, 'Result is in March (month 3), not February' ); +} + +{ + # TEST 5.2: Mixed operations - order matters + # (add_months then add seconds) vs (add seconds then add_months) + my $base = gmtime( timegm( 0, 0, 12, 31, 0, 113 ) ); # 2013-01-31 12:00:00 + + # Path 1: add_months(1) then + 86400 (1 day) + my $path1 = + $base->add_months(1) + 86400; # Jan 31→Mar 3, then +1 day = Mar 4 + + # Path 2: + 86400 (1 day) then add_months(1) + my $path2 = + ( $base + 86400 )->add_months(1); # Jan 31→Feb 1, then +1 month = Mar 1 + + isnt( $path1->ymd, $path2->ymd, + 'ODDITY: Order of operations matters (add_months vs add seconds)' ); + is( $path1->ymd, '2013-03-04', + 'add_months first: Jan 31→Mar 3, then +1 day = Mar 4' ); + is( $path2->ymd, '2013-03-01', + 'add seconds first: Jan 31→Feb 1, then +1 month = Mar 1' ); +} + +{ + # TEST 5.3: Subtraction is always epoch-based + # Even for large time differences, result is always epoch seconds + my $t1 = gmtime( timegm( 0, 0, 0, 1, 0, 114 ) ); # 2014-01-01 00:00:00 + my $t2 = gmtime( timegm( 0, 0, 0, 1, 0, 113 ) ); # 2013-01-01 00:00:00 + + my $diff = $t1 - $t2; + + isa_ok( $diff, 'Time::Seconds', + 'Subtraction always returns Time::Seconds' ); + is( $diff->days, 365, 'One year apart = 365 days (2013 not leap year)' ); + + # Can also express as months/years, but these are approximations + ok( $diff->months > 11 && $diff->months < 13, + 'Time::Seconds can approximate months (but epoch-based)' ); +} + +{ + # TEST 5.4: Time::Piece + Time::Piece behavior + # POD claims this "will cause a runtime error" but it actually doesn't + # Instead, the right-hand Time::Piece is converted to its epoch value + my $t1 = gmtime(1000000000); # 2001-09-09 01:46:40 + my $t2 = gmtime(3600); # 1970-01-01 01:00:00 (3600 epoch seconds) + + my $result; + eval { $result = $t1 + $t2; }; + + ok( !$@, + 'Time::Piece + Time::Piece does NOT throw error (contrary to POD claim)' + ); + isa_ok( $result, 'Time::Piece', 'Result is a Time::Piece object' ); + is( $result->epoch, 1000003600, + 'Time::Piece + Time::Piece: RHS converted to epoch seconds and added' ); + + # This is equivalent to $t1 + $t2->epoch + my $expected = $t1 + $t2->epoch; + is( $result->epoch, $expected->epoch, + 'Time::Piece + Time::Piece equivalent to Time::Piece + epoch' ); +} + +done_testing();